osdy-pi 1.2.0 → 1.3.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.
Files changed (44) hide show
  1. package/README.md +160 -30
  2. package/bin/osdy-pi.mjs +29 -6
  3. package/bin/osdy.mjs +43 -0
  4. package/extensions/osdy-pi/account-profiles.test.ts +111 -97
  5. package/extensions/osdy-pi/account-profiles.ts +91 -162
  6. package/extensions/osdy-pi/animation.test.ts +84 -0
  7. package/extensions/osdy-pi/animation.ts +17 -8
  8. package/extensions/osdy-pi/codex-usage-ui.test.ts +1145 -0
  9. package/extensions/osdy-pi/codex-usage-ui.ts +728 -0
  10. package/extensions/osdy-pi/codex-usage.test.ts +276 -0
  11. package/extensions/osdy-pi/codex-usage.ts +276 -0
  12. package/extensions/osdy-pi/constants.ts +134 -34
  13. package/extensions/osdy-pi/context-usage-ui.test.ts +168 -0
  14. package/extensions/osdy-pi/context-usage-ui.ts +60 -0
  15. package/extensions/osdy-pi/editor-settings.test.ts +121 -23
  16. package/extensions/osdy-pi/editor-settings.ts +39 -3
  17. package/extensions/osdy-pi/metrics.test.ts +95 -0
  18. package/extensions/osdy-pi/metrics.ts +39 -1
  19. package/extensions/osdy-pi/profile-label.ts +2 -2
  20. package/extensions/osdy-pi/runtime-helpers.test.ts +220 -1
  21. package/extensions/osdy-pi/runtime-helpers.ts +42 -19
  22. package/extensions/osdy-pi/runtime.test.ts +355 -7
  23. package/extensions/osdy-pi/runtime.ts +344 -66
  24. package/extensions/osdy-pi/types.ts +26 -1
  25. package/extensions/osdy-pi/ui.test.ts +128 -0
  26. package/extensions/osdy-pi/ui.ts +57 -40
  27. package/package.json +5 -2
  28. package/scripts/osdy-pi-account-profiles.mjs +521 -76
  29. package/scripts/osdy-pi-gentle-coexistence.mjs +200 -0
  30. package/scripts/osdy-pi-profile-setup.mjs +175 -0
  31. package/themes/osdy-pi-catppuccin-frappe.json +6 -2
  32. package/themes/osdy-pi-catppuccin-latte.json +6 -2
  33. package/themes/osdy-pi-catppuccin-macchiato.json +6 -2
  34. package/themes/osdy-pi-catppuccin-mocha.json +6 -2
  35. package/themes/osdy-pi-dark.json +6 -2
  36. package/themes/osdy-pi-dracula.json +6 -2
  37. package/themes/osdy-pi-kanagawa-dragon.json +6 -2
  38. package/themes/osdy-pi-kanagawa-lotus.json +6 -2
  39. package/themes/osdy-pi-kanagawa-wave.json +6 -2
  40. package/themes/osdy-pi-lucent-orange.json +6 -2
  41. package/themes/osdy-pi-matrix.json +6 -2
  42. package/themes/osdy-pi-new.json +6 -2
  43. package/themes/osdy-pi-sexy.json +6 -2
  44. package/themes/osdy-pi-tokyo-night.json +6 -2
@@ -1,8 +1,15 @@
1
+ import {
2
+ constants as fsConstants,
3
+ lstatSync,
4
+ realpathSync,
5
+ statSync,
6
+ } from "node:fs";
1
7
  import {
2
8
  chmod,
3
9
  lstat,
4
10
  mkdir,
5
11
  open,
12
+ readFile,
6
13
  readlink,
7
14
  readdir,
8
15
  rename,
@@ -14,12 +21,38 @@ import {
14
21
  import { Buffer } from "node:buffer";
15
22
  import { randomUUID } from "node:crypto";
16
23
  import { homedir } from "node:os";
17
- import { basename, isAbsolute, join, resolve } from "node:path";
24
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
25
+ import { setTimeout as delay } from "node:timers/promises";
18
26
 
19
- const PROFILE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
27
+ const PROFILE_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})$/;
20
28
  const RESERVED_PROFILE_NAMES = new Set(["default", "profiles", "auth.json"]);
29
+
30
+ function profileIdentity(name) {
31
+ return name.toLowerCase();
32
+ }
33
+
34
+ function sameProfile(left, right) {
35
+ return profileIdentity(left) === profileIdentity(right);
36
+ }
37
+
38
+ function assertUniqueProfileIdentities(profiles) {
39
+ const identities = new Set();
40
+ for (const profile of profiles) {
41
+ const identity = profileIdentity(profile);
42
+ if (identities.has(identity))
43
+ throw new Error("Profile lookup is ambiguous due to a case-insensitive collision.");
44
+ identities.add(identity);
45
+ }
46
+ }
21
47
  const DEFAULT_ACCOUNT_FILE = "default-account.json";
48
+ const ACTIVE_ACCOUNT_FILE = "active-account.json";
22
49
  const MAX_DEFAULT_ACCOUNT_BYTES = 4096;
50
+ const MAX_AUTH_BYTES = 4 * 1024 * 1024;
51
+ const ACCOUNT_SWITCH_LOCK_FILE = "account-switch.lock";
52
+ const PROFILE_NAMESPACE_LOCK_FILE = "profile-namespace.lock";
53
+ const PRIVATE_LOCK_TIMEOUT_MS = 2000;
54
+ const PRIVATE_LOCK_RETRY_MS = 25;
55
+ let accountSwitch = Promise.resolve();
23
56
  const SHARED_STATE = [
24
57
  "settings.json",
25
58
  "models.json",
@@ -47,23 +80,36 @@ export function validateProfileName(value) {
47
80
  if (
48
81
  typeof value !== "string" ||
49
82
  !PROFILE_NAME.test(value) ||
50
- RESERVED_PROFILE_NAMES.has(value) ||
83
+ RESERVED_PROFILE_NAMES.has(profileIdentity(value)) ||
51
84
  isAbsolute(value) ||
52
85
  basename(value) !== value
53
86
  ) {
54
87
  throw new Error(
55
- "Profile names must use lowercase letters, numbers, and hyphens (not reserved names).",
88
+ "Profile names must use ASCII letters, numbers, and hyphens (not reserved names).",
56
89
  );
57
90
  }
58
91
  return value;
59
92
  }
60
93
 
61
94
  export function getSharedAgentDir(env = process.env) {
62
- return resolve(
63
- env.OSDY_PI_SHARED_AGENT_DIR ??
64
- env.PI_CODING_AGENT_DIR ??
65
- join(homedir(), ".pi", "agent"),
95
+ const explicit = env.OSDY_PI_SHARED_AGENT_DIR;
96
+ const candidate = resolve(
97
+ explicit ?? env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
66
98
  );
99
+ if (explicit !== undefined) return candidate;
100
+ const managedPath = join(candidate, "osdy-pi");
101
+ try {
102
+ if (!lstatSync(managedPath).isSymbolicLink()) return candidate;
103
+ const resolvedManagedPath = realpathSync(managedPath);
104
+ if (
105
+ basename(resolvedManagedPath) !== "osdy-pi" ||
106
+ !statSync(resolvedManagedPath).isDirectory()
107
+ )
108
+ return candidate;
109
+ return dirname(resolvedManagedPath);
110
+ } catch {
111
+ return candidate;
112
+ }
67
113
  }
68
114
 
69
115
  export function createProfileLayout(sharedAgentDir, name) {
@@ -146,7 +192,6 @@ export function planPiLaunch(
146
192
  env = process.env,
147
193
  ) {
148
194
  const safeName = validateProfileName(name);
149
- const layout = createProfileLayout(sharedAgentDir, safeName);
150
195
  const devExtensionRoot = env.OSDY_PI_DEV_EXTENSION_ROOT;
151
196
  if (
152
197
  devExtensionRoot !== undefined &&
@@ -161,7 +206,7 @@ export function planPiLaunch(
161
206
  ? [...piArgs]
162
207
  : ["-e", devExtensionRoot, ...piArgs],
163
208
  env: {
164
- PI_CODING_AGENT_DIR: layout.profileDir,
209
+ PI_CODING_AGENT_DIR: resolve(sharedAgentDir),
165
210
  OSDY_PI_PROFILE_NAME: safeName,
166
211
  OSDY_PI_SHARED_AGENT_DIR: resolve(sharedAgentDir),
167
212
  PI_CODING_AGENT_SESSION_DIR: join(resolve(sharedAgentDir), "sessions"),
@@ -176,7 +221,11 @@ async function ensurePrivateDirectory(path) {
176
221
  throw new Error(`Managed profile path is not a directory: ${path}`);
177
222
  } catch (error) {
178
223
  if (!isErrorCode(error, "ENOENT")) throw error;
179
- await mkdir(path, { mode: 0o700 });
224
+ try {
225
+ await mkdir(path, { mode: 0o700 });
226
+ } catch (mkdirError) {
227
+ if (!isErrorCode(mkdirError, "EEXIST")) throw mkdirError;
228
+ }
180
229
  const stat = await lstat(path);
181
230
  if (!stat.isDirectory())
182
231
  throw new Error(`Managed profile path is not a directory: ${path}`);
@@ -209,49 +258,81 @@ async function ensureSharedLink(target, source) {
209
258
  await symlink(source, target);
210
259
  }
211
260
 
212
- export async function ensureProfileLayout(sharedAgentDir, name) {
261
+ async function findExistingProfileName(profilesDir, name) {
262
+ const matches = [];
263
+ try {
264
+ const entries = await readdir(profilesDir, { withFileTypes: true });
265
+ for (const entry of entries)
266
+ if (
267
+ entry.isDirectory() &&
268
+ PROFILE_NAME.test(entry.name) &&
269
+ !RESERVED_PROFILE_NAMES.has(profileIdentity(entry.name)) &&
270
+ sameProfile(entry.name, name)
271
+ )
272
+ matches.push(entry.name);
273
+ } catch (error) {
274
+ if (isErrorCode(error, "ENOENT")) return undefined;
275
+ throw error;
276
+ }
277
+ if (matches.length > 1)
278
+ throw new Error("Profile lookup is ambiguous due to a case-insensitive collision.");
279
+ return matches[0];
280
+ }
281
+
282
+ export async function ensureProfileLayout(sharedAgentDir, name, { hooks = {} } = {}) {
213
283
  const sharedDir = resolve(sharedAgentDir);
214
284
  const layout = createProfileLayout(sharedDir, name);
215
285
  await ensurePrivateDirectory(join(sharedDir, "osdy-pi"));
216
- await ensurePrivateDirectory(layout.profilesDir);
217
- await ensurePrivateDirectory(layout.profileDir);
218
- for (const stateName of layout.sharedState) {
219
- await ensureSharedLink(
220
- join(layout.profileDir, stateName),
221
- join(sharedDir, stateName),
222
- );
223
- }
224
- return layout;
286
+ return withProfileNamespaceLock(sharedDir, hooks, async () => {
287
+ await ensurePrivateDirectory(layout.profilesDir);
288
+ const existingName = await findExistingProfileName(layout.profilesDir, name);
289
+ if (existingName !== undefined && existingName !== basename(layout.profileDir))
290
+ throw new Error("A profile with the same case-insensitive name already exists.");
291
+ await ensurePrivateDirectory(layout.profileDir);
292
+ for (const stateName of layout.sharedState) {
293
+ await ensureSharedLink(
294
+ join(layout.profileDir, stateName),
295
+ join(sharedDir, stateName),
296
+ );
297
+ }
298
+ return layout;
299
+ });
225
300
  }
226
301
 
227
302
  export function getDefaultAccountPath(sharedAgentDir) {
228
303
  return join(resolve(sharedAgentDir), "osdy-pi", DEFAULT_ACCOUNT_FILE);
229
304
  }
230
305
 
306
+ export function getActiveAccountPath(sharedAgentDir) {
307
+ return join(resolve(sharedAgentDir), "osdy-pi", ACTIVE_ACCOUNT_FILE);
308
+ }
309
+
231
310
  export async function validateExistingProfile(sharedAgentDir, name) {
232
311
  const safeName = validateProfileName(name);
233
- const layout = createProfileLayout(sharedAgentDir, safeName);
234
- const confinedRoot = `${resolve(layout.profilesDir)}/`;
235
- if (!resolve(layout.profileDir).startsWith(confinedRoot))
236
- throw new Error(
237
- "Profile directory is outside the managed profiles directory.",
238
- );
312
+ const requestedLayout = createProfileLayout(sharedAgentDir, safeName);
239
313
  try {
240
314
  const osdyPiStat = await lstat(join(resolve(sharedAgentDir), "osdy-pi"));
241
315
  if (!osdyPiStat.isDirectory())
242
316
  throw new Error("Managed account path is not a directory.");
243
- const profilesStat = await lstat(layout.profilesDir);
317
+ const profilesStat = await lstat(requestedLayout.profilesDir);
244
318
  if (!profilesStat.isDirectory())
245
319
  throw new Error("Managed profiles path is not a directory.");
320
+ const canonicalName = await findExistingProfileName(
321
+ requestedLayout.profilesDir,
322
+ safeName,
323
+ );
324
+ if (canonicalName === undefined)
325
+ throw new Error("The selected default must be an existing profile.");
326
+ const layout = createProfileLayout(sharedAgentDir, canonicalName);
246
327
  const stat = await lstat(layout.profileDir);
247
328
  if (!stat.isDirectory())
248
329
  throw new Error("The selected profile is not an existing directory.");
330
+ return { ...layout, name: canonicalName };
249
331
  } catch (error) {
250
332
  if (isErrorCode(error, "ENOENT"))
251
333
  throw new Error("The selected default must be an existing profile.");
252
334
  throw error;
253
335
  }
254
- return { ...layout, name: safeName };
255
336
  }
256
337
 
257
338
  async function readBoundedRegularFile(path) {
@@ -287,38 +368,345 @@ export async function readDefaultAccount(sharedAgentDir) {
287
368
  typeof parsed.profile !== "string"
288
369
  )
289
370
  return { status: "invalid" };
290
- await validateExistingProfile(sharedAgentDir, parsed.profile);
291
- return { status: "valid", profile: parsed.profile };
371
+ const profile = await validateExistingProfile(sharedAgentDir, parsed.profile);
372
+ return { status: "valid", profile: profile.name };
292
373
  } catch (error) {
293
374
  if (isErrorCode(error, "ENOENT")) return { status: "unset" };
294
375
  return { status: "invalid" };
295
376
  }
296
377
  }
297
378
 
298
- export async function setDefaultAccount(sharedAgentDir, name) {
379
+ async function replacePrivateFile(path, contents) {
380
+ const directory = resolve(path, "..");
381
+ const filename = basename(path);
382
+ const temporary = join(
383
+ directory,
384
+ `.${filename}.${process.pid}.${randomUUID()}.tmp`,
385
+ );
386
+ try {
387
+ await writeFile(temporary, contents, {
388
+ mode: 0o600,
389
+ flag: "wx",
390
+ });
391
+ await chmod(temporary, 0o600);
392
+ await rename(temporary, path);
393
+ await chmod(path, 0o600);
394
+ } catch (error) {
395
+ await unlink(temporary).catch(() => {});
396
+ throw error;
397
+ }
398
+ }
399
+
400
+ async function writeAccountMetadata(sharedAgentDir, filename, name) {
299
401
  const layout = await validateExistingProfile(sharedAgentDir, name);
300
- const defaultDir = join(resolve(sharedAgentDir), "osdy-pi");
301
- await ensurePrivateDirectory(defaultDir);
302
- const defaultPath = getDefaultAccountPath(sharedAgentDir);
303
- const tempPath = join(
304
- defaultDir,
305
- `.${DEFAULT_ACCOUNT_FILE}.${process.pid}.${randomUUID()}.tmp`,
402
+ const metadataDir = join(resolve(sharedAgentDir), "osdy-pi");
403
+ await ensurePrivateDirectory(metadataDir);
404
+ await replacePrivateFile(
405
+ join(metadataDir, filename),
406
+ `${JSON.stringify({ version: 1, profile: layout.name })}\n`,
407
+ );
408
+ }
409
+
410
+ async function captureMetadata(sharedAgentDir, filename) {
411
+ const path = join(resolve(sharedAgentDir), "osdy-pi", filename);
412
+ try {
413
+ const stat = await lstat(path);
414
+ if (!stat.isFile() || stat.size > MAX_DEFAULT_ACCOUNT_BYTES)
415
+ throw new Error("Account metadata is not a bounded regular file.");
416
+ return { path, contents: await readFile(path, "utf8") };
417
+ } catch (error) {
418
+ if (isErrorCode(error, "ENOENT")) return { path, contents: undefined };
419
+ throw error;
420
+ }
421
+ }
422
+
423
+ async function restoreMetadata(snapshot) {
424
+ if (snapshot.contents === undefined) {
425
+ await unlink(snapshot.path).catch((error) => {
426
+ if (!isErrorCode(error, "ENOENT")) throw error;
427
+ });
428
+ return;
429
+ }
430
+ await replacePrivateFile(snapshot.path, snapshot.contents);
431
+ }
432
+
433
+ async function updateSelectionMetadata(sharedAgentDir, name, hooks = {}) {
434
+ const activeBefore = await captureMetadata(
435
+ sharedAgentDir,
436
+ ACTIVE_ACCOUNT_FILE,
306
437
  );
438
+ const defaultBefore = await captureMetadata(
439
+ sharedAgentDir,
440
+ DEFAULT_ACCOUNT_FILE,
441
+ );
442
+ try {
443
+ await setActiveAccount(sharedAgentDir, name);
444
+ await hooks.afterActiveWrite?.();
445
+ await setDefaultAccount(sharedAgentDir, name);
446
+ } catch (error) {
447
+ const rollbackErrors = [];
448
+ for (const [snapshot, restore] of [
449
+ [activeBefore, hooks.restoreActiveMetadata ?? restoreMetadata],
450
+ [defaultBefore, hooks.restoreDefaultMetadata ?? restoreMetadata],
451
+ ]) {
452
+ try {
453
+ await restore(snapshot);
454
+ } catch (rollbackError) {
455
+ rollbackErrors.push(boundedError(rollbackError));
456
+ }
457
+ }
458
+ if (rollbackErrors.length > 0) {
459
+ throw new Error(
460
+ `Selection metadata update failed: ${boundedError(error)}; rollback failures: ${rollbackErrors.join("; ")}`,
461
+ { cause: error },
462
+ );
463
+ }
464
+ throw error;
465
+ }
466
+ }
467
+
468
+ export async function setDefaultAccount(sharedAgentDir, name) {
469
+ await writeAccountMetadata(sharedAgentDir, DEFAULT_ACCOUNT_FILE, name);
470
+ }
471
+
472
+ export async function setActiveAccount(sharedAgentDir, name) {
473
+ await writeAccountMetadata(sharedAgentDir, ACTIVE_ACCOUNT_FILE, name);
474
+ }
475
+
476
+ export async function readActiveAccount(sharedAgentDir) {
477
+ const activePath = getActiveAccountPath(sharedAgentDir);
478
+ try {
479
+ const parsed = JSON.parse(await readBoundedRegularFile(activePath));
480
+ if (
481
+ typeof parsed !== "object" ||
482
+ parsed === null ||
483
+ Object.keys(parsed).length !== 2 ||
484
+ parsed.version !== 1 ||
485
+ typeof parsed.profile !== "string"
486
+ )
487
+ return { status: "invalid" };
488
+ const profile = await validateExistingProfile(sharedAgentDir, parsed.profile);
489
+ return { status: "valid", profile: profile.name };
490
+ } catch (error) {
491
+ if (isErrorCode(error, "ENOENT")) return { status: "unset" };
492
+ return { status: "invalid" };
493
+ }
494
+ }
495
+
496
+ async function captureOptionalAuth(path) {
497
+ try {
498
+ const sourceStat = await lstat(path);
499
+ if (!sourceStat.isFile() || sourceStat.size > MAX_AUTH_BYTES)
500
+ throw new Error("Account auth is not a bounded regular file.");
501
+ const handle = await open(
502
+ path,
503
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0),
504
+ );
505
+ try {
506
+ const openedStat = await handle.stat();
507
+ if (
508
+ openedStat.dev !== sourceStat.dev ||
509
+ openedStat.ino !== sourceStat.ino ||
510
+ !openedStat.isFile() ||
511
+ openedStat.size > MAX_AUTH_BYTES
512
+ )
513
+ throw new Error("Account auth changed while switching accounts.");
514
+ const contents = Buffer.alloc(openedStat.size);
515
+ const { bytesRead } = await handle.read(contents, 0, contents.length, 0);
516
+ if (bytesRead !== openedStat.size)
517
+ throw new Error("Account auth changed while switching accounts.");
518
+ return contents;
519
+ } finally {
520
+ await handle.close();
521
+ }
522
+ } catch (error) {
523
+ if (isErrorCode(error, "ENOENT")) return undefined;
524
+ throw error;
525
+ }
526
+ }
527
+
528
+ async function restoreOptionalAuth(path, contents) {
529
+ if (contents === undefined) {
530
+ await unlink(path).catch((error) => {
531
+ if (!isErrorCode(error, "ENOENT")) throw error;
532
+ });
533
+ return;
534
+ }
535
+ await replacePrivateFile(path, contents);
536
+ }
537
+
538
+ async function copyRegularAuth(source, destination, hooks = {}) {
539
+ const sourceStat = await lstat(source);
540
+ if (!sourceStat.isFile() || sourceStat.size > MAX_AUTH_BYTES)
541
+ throw new Error("Account auth is not a bounded regular file.");
542
+ await hooks.beforeOpenAuth?.(source);
543
+ const noFollow = fsConstants.O_NOFOLLOW ?? 0;
544
+ let sourceHandle;
545
+ try {
546
+ sourceHandle = await open(source, fsConstants.O_RDONLY | noFollow);
547
+ } catch (error) {
548
+ if (isErrorCode(error, "ELOOP"))
549
+ throw new Error("Account auth is not a bounded regular file.");
550
+ throw error;
551
+ }
307
552
  try {
308
- await writeFile(
309
- tempPath,
310
- `${JSON.stringify({ version: 1, profile: layout.name })}\n`,
311
- { encoding: "utf8", mode: 0o600, flag: "wx" },
553
+ const openedStat = await sourceHandle.stat();
554
+ if (
555
+ openedStat.dev !== sourceStat.dev ||
556
+ openedStat.ino !== sourceStat.ino ||
557
+ !openedStat.isFile() ||
558
+ openedStat.size > MAX_AUTH_BYTES
559
+ )
560
+ throw new Error("Account auth is not a bounded regular file.");
561
+ const contents = Buffer.alloc(openedStat.size);
562
+ const { bytesRead } = await sourceHandle.read(
563
+ contents,
564
+ 0,
565
+ contents.length,
566
+ 0,
567
+ );
568
+ if (bytesRead !== openedStat.size)
569
+ throw new Error("Account auth changed while switching accounts.");
570
+ const directory = resolve(destination, "..");
571
+ const temporary = join(
572
+ directory,
573
+ `.auth.json.${process.pid}.${randomUUID()}.tmp`,
312
574
  );
313
- await chmod(tempPath, 0o600);
314
- await rename(tempPath, defaultPath);
315
- await chmod(defaultPath, 0o600);
575
+ try {
576
+ await writeFile(temporary, contents, { mode: 0o600, flag: "wx" });
577
+ await chmod(temporary, 0o600);
578
+ await rename(temporary, destination);
579
+ await chmod(destination, 0o600);
580
+ } catch (error) {
581
+ await unlink(temporary).catch(() => {});
582
+ throw error;
583
+ }
584
+ } finally {
585
+ await sourceHandle.close();
586
+ }
587
+ }
588
+
589
+ async function assertOptionalRegularAuth(path) {
590
+ try {
591
+ const stat = await lstat(path);
592
+ if (!stat.isFile() || stat.size > MAX_AUTH_BYTES)
593
+ throw new Error("Account auth is not a bounded regular file.");
594
+ return true;
316
595
  } catch (error) {
317
- await unlink(tempPath).catch(() => {});
596
+ if (isErrorCode(error, "ENOENT")) return false;
318
597
  throw error;
319
598
  }
320
599
  }
321
600
 
601
+ async function removeOwnedPrivateLock(lockPath, lockIdentity) {
602
+ const currentLock = await lstat(lockPath).catch(() => undefined);
603
+ if (
604
+ currentLock?.dev === lockIdentity.dev &&
605
+ currentLock.ino === lockIdentity.ino
606
+ )
607
+ await unlink(lockPath).catch(() => {});
608
+ }
609
+
610
+ async function withPrivateLock(sharedDir, lockFile, timeoutMessage, hooks, operation) {
611
+ const lockPath = join(sharedDir, "osdy-pi", lockFile);
612
+ const deadline = Date.now() + PRIVATE_LOCK_TIMEOUT_MS;
613
+ let handle;
614
+ let lockIdentity;
615
+ while (handle === undefined) {
616
+ let candidateHandle;
617
+ let candidateIdentity;
618
+ try {
619
+ candidateHandle = await open(lockPath, "wx", 0o600);
620
+ candidateIdentity = await candidateHandle.stat();
621
+ await hooks.afterLockCreated?.();
622
+ await candidateHandle.writeFile(`${JSON.stringify({ pid: process.pid })}\n`);
623
+ await candidateHandle.chmod(0o600);
624
+ handle = candidateHandle;
625
+ lockIdentity = candidateIdentity;
626
+ } catch (error) {
627
+ if (candidateHandle !== undefined) {
628
+ await candidateHandle.close().catch(() => {});
629
+ if (candidateIdentity !== undefined)
630
+ await removeOwnedPrivateLock(lockPath, candidateIdentity);
631
+ }
632
+ if (!isErrorCode(error, "EEXIST")) throw error;
633
+ if (Date.now() >= deadline) throw new Error(timeoutMessage);
634
+ await delay(PRIVATE_LOCK_RETRY_MS);
635
+ }
636
+ }
637
+ try {
638
+ await hooks.afterLockAcquired?.();
639
+ return await operation();
640
+ } finally {
641
+ await handle.close().catch(() => {});
642
+ await removeOwnedPrivateLock(lockPath, lockIdentity);
643
+ }
644
+ }
645
+
646
+ function withAccountSwitchLock(sharedDir, hooks, operation) {
647
+ return withPrivateLock(
648
+ sharedDir,
649
+ ACCOUNT_SWITCH_LOCK_FILE,
650
+ "Timed out waiting for account switch lock.",
651
+ hooks,
652
+ operation,
653
+ );
654
+ }
655
+
656
+ function withProfileNamespaceLock(sharedDir, hooks, operation) {
657
+ return withPrivateLock(
658
+ sharedDir,
659
+ PROFILE_NAMESPACE_LOCK_FILE,
660
+ "Timed out waiting for profile namespace lock.",
661
+ hooks,
662
+ operation,
663
+ );
664
+ }
665
+
666
+ async function switchAccountAuthNow(sharedAgentDir, destination, hooks = {}) {
667
+ const target = await validateExistingProfile(sharedAgentDir, destination);
668
+ const sharedDir = resolve(sharedAgentDir);
669
+ await ensurePrivateDirectory(sharedDir);
670
+ return withAccountSwitchLock(sharedDir, hooks, async () => {
671
+ const canonicalAuth = join(sharedDir, "auth.json");
672
+ const targetHasAuth = await assertOptionalRegularAuth(target.authPath);
673
+ const active = await readActiveAccount(sharedDir);
674
+ const source =
675
+ active.status === "valid"
676
+ ? await validateExistingProfile(sharedDir, active.profile)
677
+ : undefined;
678
+ const canonicalExists = await assertOptionalRegularAuth(canonicalAuth);
679
+ const canonicalBefore = await captureOptionalAuth(canonicalAuth);
680
+ if (source && sameProfile(source.name, target.name)) {
681
+ await updateSelectionMetadata(sharedDir, target.name, hooks);
682
+ return;
683
+ }
684
+ if (source && canonicalExists)
685
+ await copyRegularAuth(canonicalAuth, source.authPath, hooks);
686
+ try {
687
+ if (targetHasAuth)
688
+ await copyRegularAuth(target.authPath, canonicalAuth, hooks);
689
+ else if (canonicalExists) await unlink(canonicalAuth);
690
+ await updateSelectionMetadata(sharedDir, target.name, hooks);
691
+ } catch (error) {
692
+ await restoreOptionalAuth(canonicalAuth, canonicalBefore).catch(() => {});
693
+ throw error;
694
+ }
695
+ });
696
+ }
697
+
698
+ export function switchAccountAuth(
699
+ sharedAgentDir,
700
+ destination,
701
+ { hooks = {} } = {},
702
+ ) {
703
+ const next = accountSwitch.then(() =>
704
+ switchAccountAuthNow(sharedAgentDir, destination, hooks),
705
+ );
706
+ accountSwitch = next.catch(() => {});
707
+ return next;
708
+ }
709
+
322
710
  export async function clearDefaultAccount(sharedAgentDir) {
323
711
  try {
324
712
  await unlink(getDefaultAccountPath(sharedAgentDir));
@@ -328,18 +716,32 @@ export async function clearDefaultAccount(sharedAgentDir) {
328
716
  }
329
717
 
330
718
  function refuseActiveProfile(name, activeProfile) {
331
- if (activeProfile === name)
719
+ if (typeof activeProfile === "string" && sameProfile(activeProfile, name))
332
720
  throw new Error(
333
721
  "Cannot change the active profile. Close this Pi process first.",
334
722
  );
335
723
  }
336
724
 
337
- async function assertMissingProfilePath(path) {
338
- try {
339
- await lstat(path);
725
+ async function assertMissingProfilePath(profilesDir, name) {
726
+ const existingName = await findExistingProfileName(profilesDir, name);
727
+ if (existingName !== undefined)
340
728
  throw new Error("The new profile path already exists.");
729
+ }
730
+
731
+ async function moveProfileDirectory(oldPath, newPath, profilesDir) {
732
+ if (!sameProfile(basename(oldPath), basename(newPath))) {
733
+ await rename(oldPath, newPath);
734
+ return;
735
+ }
736
+ const temporary = join(
737
+ profilesDir,
738
+ `.profile-rename.${process.pid}.${randomUUID()}.tmp`,
739
+ );
740
+ await rename(oldPath, temporary);
741
+ try {
742
+ await rename(temporary, newPath);
341
743
  } catch (error) {
342
- if (isErrorCode(error, "ENOENT")) return;
744
+ await rename(temporary, oldPath).catch(() => {});
343
745
  throw error;
344
746
  }
345
747
  }
@@ -351,41 +753,56 @@ export async function renameProfile(
351
753
  {
352
754
  activeProfile = process.env.OSDY_PI_PROFILE_NAME,
353
755
  setDefault = setDefaultAccount,
756
+ hooks = {},
354
757
  } = {},
355
758
  ) {
356
- const oldProfile = await validateExistingProfile(sharedAgentDir, oldName);
759
+ const sharedDir = resolve(sharedAgentDir);
760
+ const safeOldName = validateProfileName(oldName);
357
761
  const safeNewName = validateProfileName(newName);
358
- refuseActiveProfile(oldProfile.name, activeProfile);
359
- const newProfile = createProfileLayout(sharedAgentDir, safeNewName);
360
- await assertMissingProfilePath(newProfile.profileDir);
361
- const defaultAccount = await readDefaultAccount(sharedAgentDir);
362
- await rename(oldProfile.profileDir, newProfile.profileDir);
363
- if (
364
- defaultAccount.status !== "valid" ||
365
- defaultAccount.profile !== oldProfile.name
366
- )
367
- return;
368
- try {
369
- await setDefault(sharedAgentDir, safeNewName);
370
- } catch (error) {
762
+ await ensurePrivateDirectory(join(sharedDir, "osdy-pi"));
763
+ return withProfileNamespaceLock(sharedDir, hooks, async () => {
764
+ const oldProfile = await validateExistingProfile(sharedDir, safeOldName);
765
+ refuseActiveProfile(oldProfile.name, activeProfile);
766
+ const newProfile = createProfileLayout(sharedDir, safeNewName);
767
+ if (!sameProfile(oldProfile.name, safeNewName))
768
+ await assertMissingProfilePath(newProfile.profilesDir, safeNewName);
769
+ const defaultAccount = await readDefaultAccount(sharedDir);
770
+ await moveProfileDirectory(
771
+ oldProfile.profileDir,
772
+ newProfile.profileDir,
773
+ newProfile.profilesDir,
774
+ );
775
+ if (
776
+ defaultAccount.status !== "valid" ||
777
+ !sameProfile(defaultAccount.profile, oldProfile.name)
778
+ )
779
+ return;
371
780
  try {
372
- await rename(newProfile.profileDir, oldProfile.profileDir);
373
- } catch (rollbackError) {
781
+ await setDefault(sharedDir, safeNewName);
782
+ } catch (error) {
783
+ try {
784
+ await moveProfileDirectory(
785
+ newProfile.profileDir,
786
+ oldProfile.profileDir,
787
+ newProfile.profilesDir,
788
+ );
789
+ } catch (rollbackError) {
790
+ throw new Error(
791
+ `Default update failed and profile rollback failed: ${boundedError(error)}; ${boundedError(rollbackError)}`,
792
+ );
793
+ }
374
794
  throw new Error(
375
- `Default update failed and profile rollback failed: ${boundedError(error)}; ${boundedError(rollbackError)}`,
795
+ `Default update failed; profile rename was rolled back: ${boundedError(error)}`,
376
796
  );
377
797
  }
378
- throw new Error(
379
- `Default update failed; profile rename was rolled back: ${boundedError(error)}`,
380
- );
381
- }
798
+ });
382
799
  }
383
800
 
384
801
  function boundedError(error) {
385
802
  return error instanceof Error ? error.message.slice(0, 180) : "unknown error";
386
803
  }
387
804
 
388
- export async function removeProfile(
805
+ async function removeProfileNow(
389
806
  sharedAgentDir,
390
807
  name,
391
808
  {
@@ -401,12 +818,12 @@ export async function removeProfile(
401
818
  const defaultAccount = await readDefaultAccount(sharedAgentDir);
402
819
  if (
403
820
  defaultAccount.status === "valid" &&
404
- defaultAccount.profile === profile.name
821
+ sameProfile(defaultAccount.profile, profile.name)
405
822
  ) {
406
823
  if (replacement === undefined)
407
824
  throw new Error("Removing the default profile requires a replacement.");
408
825
  const safeReplacement = validateProfileName(replacement);
409
- if (safeReplacement === profile.name)
826
+ if (sameProfile(safeReplacement, profile.name))
410
827
  throw new Error(
411
828
  "The default replacement must be different from the removed profile.",
412
829
  );
@@ -455,6 +872,28 @@ export async function removeProfile(
455
872
  }
456
873
  }
457
874
 
875
+ export async function removeProfile(
876
+ sharedAgentDir,
877
+ name,
878
+ {
879
+ confirmation,
880
+ replacement,
881
+ activeProfile = process.env.OSDY_PI_PROFILE_NAME,
882
+ hooks = {},
883
+ } = {},
884
+ ) {
885
+ const sharedDir = resolve(sharedAgentDir);
886
+ const safeName = validateProfileName(name);
887
+ await ensurePrivateDirectory(join(sharedDir, "osdy-pi"));
888
+ return withProfileNamespaceLock(sharedDir, hooks, () =>
889
+ removeProfileNow(sharedDir, safeName, {
890
+ confirmation,
891
+ replacement,
892
+ activeProfile,
893
+ }),
894
+ );
895
+ }
896
+
458
897
  export async function planDefaultLaunch(
459
898
  sharedAgentDir,
460
899
  piArgs = [],
@@ -462,6 +901,7 @@ export async function planDefaultLaunch(
462
901
  ) {
463
902
  const defaultAccount = await readDefaultAccount(sharedAgentDir);
464
903
  if (defaultAccount.status === "valid") {
904
+ await switchAccountAuth(sharedAgentDir, defaultAccount.profile);
465
905
  return {
466
906
  plan: planPiLaunch(sharedAgentDir, defaultAccount.profile, piArgs, env),
467
907
  defaultAccount,
@@ -476,17 +916,22 @@ export async function planDefaultLaunch(
476
916
  export async function listProfiles(sharedAgentDir) {
477
917
  const { profilesDir } = createProfileLayout(sharedAgentDir, "placeholder");
478
918
  try {
919
+ const managedDirectory = await lstat(
920
+ join(resolve(sharedAgentDir), "osdy-pi"),
921
+ );
922
+ if (!managedDirectory.isDirectory()) return [];
479
923
  const entries = await readdir(profilesDir, { withFileTypes: true });
480
924
  const profiles = [];
481
925
  for (const entry of entries) {
482
926
  if (
483
927
  entry.isDirectory() &&
484
928
  PROFILE_NAME.test(entry.name) &&
485
- !RESERVED_PROFILE_NAMES.has(entry.name)
929
+ !RESERVED_PROFILE_NAMES.has(profileIdentity(entry.name))
486
930
  ) {
487
931
  profiles.push(entry.name);
488
932
  }
489
933
  }
934
+ assertUniqueProfileIdentities(profiles);
490
935
  return profiles.sort(compareStrings);
491
936
  } catch (error) {
492
937
  if (isErrorCode(error, "ENOENT")) return [];