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.
- package/README.md +160 -30
- package/bin/osdy-pi.mjs +29 -6
- package/bin/osdy.mjs +43 -0
- package/extensions/osdy-pi/account-profiles.test.ts +111 -97
- package/extensions/osdy-pi/account-profiles.ts +91 -162
- package/extensions/osdy-pi/animation.test.ts +84 -0
- package/extensions/osdy-pi/animation.ts +17 -8
- package/extensions/osdy-pi/codex-usage-ui.test.ts +1145 -0
- package/extensions/osdy-pi/codex-usage-ui.ts +728 -0
- package/extensions/osdy-pi/codex-usage.test.ts +276 -0
- package/extensions/osdy-pi/codex-usage.ts +276 -0
- package/extensions/osdy-pi/constants.ts +134 -34
- package/extensions/osdy-pi/context-usage-ui.test.ts +168 -0
- package/extensions/osdy-pi/context-usage-ui.ts +60 -0
- package/extensions/osdy-pi/editor-settings.test.ts +121 -23
- package/extensions/osdy-pi/editor-settings.ts +39 -3
- package/extensions/osdy-pi/metrics.test.ts +95 -0
- package/extensions/osdy-pi/metrics.ts +39 -1
- package/extensions/osdy-pi/profile-label.ts +2 -2
- package/extensions/osdy-pi/runtime-helpers.test.ts +220 -1
- package/extensions/osdy-pi/runtime-helpers.ts +42 -19
- package/extensions/osdy-pi/runtime.test.ts +355 -7
- package/extensions/osdy-pi/runtime.ts +344 -66
- package/extensions/osdy-pi/types.ts +26 -1
- package/extensions/osdy-pi/ui.test.ts +128 -0
- package/extensions/osdy-pi/ui.ts +57 -40
- package/package.json +5 -2
- package/scripts/osdy-pi-account-profiles.mjs +521 -76
- package/scripts/osdy-pi-gentle-coexistence.mjs +200 -0
- package/scripts/osdy-pi-profile-setup.mjs +175 -0
- package/themes/osdy-pi-catppuccin-frappe.json +6 -2
- package/themes/osdy-pi-catppuccin-latte.json +6 -2
- package/themes/osdy-pi-catppuccin-macchiato.json +6 -2
- package/themes/osdy-pi-catppuccin-mocha.json +6 -2
- package/themes/osdy-pi-dark.json +6 -2
- package/themes/osdy-pi-dracula.json +6 -2
- package/themes/osdy-pi-kanagawa-dragon.json +6 -2
- package/themes/osdy-pi-kanagawa-lotus.json +6 -2
- package/themes/osdy-pi-kanagawa-wave.json +6 -2
- package/themes/osdy-pi-lucent-orange.json +6 -2
- package/themes/osdy-pi-matrix.json +6 -2
- package/themes/osdy-pi-new.json +6 -2
- package/themes/osdy-pi-sexy.json +6 -2
- 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 = /^[
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
301
|
-
await ensurePrivateDirectory(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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(
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
|
759
|
+
const sharedDir = resolve(sharedAgentDir);
|
|
760
|
+
const safeOldName = validateProfileName(oldName);
|
|
357
761
|
const safeNewName = validateProfileName(newName);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
defaultAccount
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
373
|
-
} catch (
|
|
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
|
|
795
|
+
`Default update failed; profile rename was rolled back: ${boundedError(error)}`,
|
|
376
796
|
);
|
|
377
797
|
}
|
|
378
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 [];
|