agent-browser-loop 0.2.1 → 0.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/.claude/skills/agent-browser-loop/REFERENCE.md +131 -0
- package/.claude/skills/agent-browser-loop/SKILL.md +36 -4
- package/README.md +41 -17
- package/package.json +1 -1
- package/src/actions.ts +25 -19
- package/src/browser.ts +67 -9
- package/src/cli.ts +461 -9
- package/src/commands.ts +11 -0
- package/src/config.ts +1 -0
- package/src/daemon.ts +57 -26
- package/src/index.ts +18 -0
- package/src/profiles.ts +414 -0
- package/src/ref-store.ts +216 -0
- package/src/server.ts +148 -0
- package/src/state.ts +236 -132
- package/src/types.ts +2 -0
package/src/daemon.ts
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
waitConditionSchema,
|
|
20
20
|
} from "./commands";
|
|
21
21
|
import { createIdGenerator } from "./id";
|
|
22
|
+
import { saveProfile } from "./profiles";
|
|
22
23
|
import { formatStateText } from "./state";
|
|
24
|
+
import type { StorageState } from "./types";
|
|
23
25
|
|
|
24
26
|
// ============================================================================
|
|
25
27
|
// Daemon Protocol
|
|
@@ -37,6 +39,10 @@ const browserOptionsSchema = z
|
|
|
37
39
|
captureNetwork: z.boolean().optional(),
|
|
38
40
|
networkLogLimit: z.number().optional(),
|
|
39
41
|
storageStatePath: z.string().optional(),
|
|
42
|
+
// Profile to load and save back on close
|
|
43
|
+
profile: z.string().optional(),
|
|
44
|
+
// If true, don't save profile on close (read-only)
|
|
45
|
+
noSave: z.boolean().optional(),
|
|
40
46
|
})
|
|
41
47
|
.optional();
|
|
42
48
|
|
|
@@ -120,6 +126,10 @@ type DaemonSession = {
|
|
|
120
126
|
lastUsed: number;
|
|
121
127
|
busy: boolean;
|
|
122
128
|
options: AgentBrowserOptions;
|
|
129
|
+
// Profile name to save back on close (if set)
|
|
130
|
+
profile?: string;
|
|
131
|
+
// If true, don't save profile on close
|
|
132
|
+
noSave?: boolean;
|
|
123
133
|
};
|
|
124
134
|
|
|
125
135
|
// ============================================================================
|
|
@@ -219,13 +229,17 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
219
229
|
// Session helpers
|
|
220
230
|
async function createSession(
|
|
221
231
|
sessionId?: string,
|
|
222
|
-
browserOptions?: AgentBrowserOptions
|
|
232
|
+
browserOptions?: AgentBrowserOptions & {
|
|
233
|
+
profile?: string;
|
|
234
|
+
noSave?: boolean;
|
|
235
|
+
},
|
|
223
236
|
): Promise<DaemonSession> {
|
|
224
237
|
const id = sessionId ?? idGenerator.next();
|
|
225
238
|
if (sessions.has(id)) {
|
|
226
239
|
throw new Error(`Session already exists: ${id}`);
|
|
227
240
|
}
|
|
228
|
-
const
|
|
241
|
+
const { profile, noSave, ...restOptions } = browserOptions ?? {};
|
|
242
|
+
const mergedOptions = { ...defaultOptions, ...restOptions };
|
|
229
243
|
const browser = createBrowser(mergedOptions);
|
|
230
244
|
await browser.start();
|
|
231
245
|
const session: DaemonSession = {
|
|
@@ -234,19 +248,13 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
234
248
|
lastUsed: Date.now(),
|
|
235
249
|
busy: false,
|
|
236
250
|
options: mergedOptions,
|
|
251
|
+
profile,
|
|
252
|
+
noSave,
|
|
237
253
|
};
|
|
238
254
|
sessions.set(id, session);
|
|
239
255
|
return session;
|
|
240
256
|
}
|
|
241
257
|
|
|
242
|
-
function getSession(sessionId: string): DaemonSession {
|
|
243
|
-
const session = sessions.get(sessionId);
|
|
244
|
-
if (!session) {
|
|
245
|
-
throw new Error(`Session not found: ${sessionId}`);
|
|
246
|
-
}
|
|
247
|
-
return session;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
258
|
function getOrDefaultSession(sessionId?: string): DaemonSession {
|
|
251
259
|
const id = sessionId ?? "default";
|
|
252
260
|
const session = sessions.get(id);
|
|
@@ -258,14 +266,34 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
258
266
|
return session;
|
|
259
267
|
}
|
|
260
268
|
|
|
261
|
-
async function closeSession(
|
|
269
|
+
async function closeSession(
|
|
270
|
+
sessionId: string,
|
|
271
|
+
): Promise<{ profileSaved?: string }> {
|
|
262
272
|
const session = sessions.get(sessionId);
|
|
263
273
|
if (!session) {
|
|
264
274
|
throw new Error(`Session not found: ${sessionId}`);
|
|
265
275
|
}
|
|
276
|
+
|
|
277
|
+
let profileSaved: string | undefined;
|
|
278
|
+
|
|
279
|
+
// Save profile if one was loaded and noSave is not set
|
|
280
|
+
if (session.profile && !session.noSave) {
|
|
281
|
+
try {
|
|
282
|
+
const storageState =
|
|
283
|
+
(await session.browser.saveStorageState()) as StorageState;
|
|
284
|
+
saveProfile(session.profile, storageState);
|
|
285
|
+
profileSaved = session.profile;
|
|
286
|
+
} catch (err) {
|
|
287
|
+
// Log but don't fail the close
|
|
288
|
+
console.error(`Failed to save profile ${session.profile}:`, err);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
266
292
|
await session.browser.stop();
|
|
267
293
|
sessions.delete(sessionId);
|
|
268
294
|
idGenerator.release(sessionId);
|
|
295
|
+
|
|
296
|
+
return { profileSaved };
|
|
269
297
|
}
|
|
270
298
|
|
|
271
299
|
const shutdown = async () => {
|
|
@@ -333,16 +361,13 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
333
361
|
parseResult.data,
|
|
334
362
|
sessions,
|
|
335
363
|
createSession,
|
|
336
|
-
getSession,
|
|
337
364
|
getOrDefaultSession,
|
|
338
365
|
closeSession,
|
|
339
|
-
idGenerator,
|
|
340
|
-
defaultOptions,
|
|
341
366
|
);
|
|
342
367
|
|
|
343
368
|
// Handle shutdown
|
|
344
369
|
if (parseResult.data.type === "shutdown") {
|
|
345
|
-
socket.write(JSON.stringify(response)
|
|
370
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
346
371
|
shutdown();
|
|
347
372
|
return;
|
|
348
373
|
}
|
|
@@ -355,7 +380,7 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
355
380
|
};
|
|
356
381
|
}
|
|
357
382
|
|
|
358
|
-
socket.write(JSON.stringify(response)
|
|
383
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
359
384
|
}
|
|
360
385
|
});
|
|
361
386
|
|
|
@@ -409,13 +434,10 @@ async function handleRequest(
|
|
|
409
434
|
sessions: Map<string, DaemonSession>,
|
|
410
435
|
createSession: (
|
|
411
436
|
sessionId?: string,
|
|
412
|
-
options?: AgentBrowserOptions,
|
|
437
|
+
options?: AgentBrowserOptions & { profile?: string; noSave?: boolean },
|
|
413
438
|
) => Promise<DaemonSession>,
|
|
414
|
-
getSession: (sessionId: string) => DaemonSession,
|
|
415
439
|
getOrDefaultSession: (sessionId?: string) => DaemonSession,
|
|
416
|
-
closeSession: (sessionId: string) => Promise<
|
|
417
|
-
idGenerator: ReturnType<typeof createIdGenerator>,
|
|
418
|
-
defaultOptions: AgentBrowserOptions,
|
|
440
|
+
closeSession: (sessionId: string) => Promise<{ profileSaved?: string }>,
|
|
419
441
|
): Promise<DaemonResponse> {
|
|
420
442
|
const { id } = request;
|
|
421
443
|
|
|
@@ -453,8 +475,12 @@ async function handleRequest(
|
|
|
453
475
|
}
|
|
454
476
|
|
|
455
477
|
case "close": {
|
|
456
|
-
await closeSession(request.sessionId);
|
|
457
|
-
return {
|
|
478
|
+
const { profileSaved } = await closeSession(request.sessionId);
|
|
479
|
+
return {
|
|
480
|
+
id,
|
|
481
|
+
success: true,
|
|
482
|
+
data: { closed: request.sessionId, profileSaved },
|
|
483
|
+
};
|
|
458
484
|
}
|
|
459
485
|
|
|
460
486
|
case "command": {
|
|
@@ -465,7 +491,12 @@ async function handleRequest(
|
|
|
465
491
|
const result = await executeCommand(session.browser, request.command);
|
|
466
492
|
// Handle close command - close the session
|
|
467
493
|
if (request.command.type === "close") {
|
|
468
|
-
await closeSession(session.id);
|
|
494
|
+
const { profileSaved } = await closeSession(session.id);
|
|
495
|
+
return {
|
|
496
|
+
id,
|
|
497
|
+
success: true,
|
|
498
|
+
data: { ...((result as object) ?? {}), profileSaved },
|
|
499
|
+
};
|
|
469
500
|
}
|
|
470
501
|
return { id, success: true, data: result };
|
|
471
502
|
} finally {
|
|
@@ -613,7 +644,7 @@ export class DaemonClient {
|
|
|
613
644
|
let buffer = "";
|
|
614
645
|
|
|
615
646
|
socket.on("connect", () => {
|
|
616
|
-
socket.write(JSON.stringify(request)
|
|
647
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
617
648
|
});
|
|
618
649
|
|
|
619
650
|
socket.on("data", (data) => {
|
|
@@ -914,7 +945,7 @@ async function spawnDaemon(
|
|
|
914
945
|
|
|
915
946
|
const child = spawn(
|
|
916
947
|
process.execPath,
|
|
917
|
-
["--bun", import.meta.dirname
|
|
948
|
+
["--bun", `${import.meta.dirname}/daemon-entry.ts`, "--config", configPath],
|
|
918
949
|
{
|
|
919
950
|
detached: true,
|
|
920
951
|
stdio: "ignore",
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,24 @@ export {
|
|
|
35
35
|
isDaemonRunning,
|
|
36
36
|
startDaemon,
|
|
37
37
|
} from "./daemon";
|
|
38
|
+
// Profiles
|
|
39
|
+
export {
|
|
40
|
+
deleteProfile,
|
|
41
|
+
importProfile,
|
|
42
|
+
listProfiles,
|
|
43
|
+
loadProfile,
|
|
44
|
+
loadStorageState,
|
|
45
|
+
type Profile,
|
|
46
|
+
type ProfileInfo,
|
|
47
|
+
type ProfileMeta,
|
|
48
|
+
resolveProfilePath,
|
|
49
|
+
resolveStorageStateOption,
|
|
50
|
+
saveProfile,
|
|
51
|
+
touchProfile,
|
|
52
|
+
} from "./profiles";
|
|
53
|
+
export type { ElementSelectors, StoredElementRef } from "./ref-store";
|
|
54
|
+
// Ref store for server-side element reference management
|
|
55
|
+
export { ElementRefStore } from "./ref-store";
|
|
38
56
|
// Server
|
|
39
57
|
export type { BrowserServerConfig } from "./server";
|
|
40
58
|
export { startBrowserServer } from "./server";
|
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { StorageState } from "./types";
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Profile Schema & Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
const profileMetaSchema = z.object({
|
|
12
|
+
createdAt: z.string().optional(),
|
|
13
|
+
lastUsedAt: z.string().optional(),
|
|
14
|
+
description: z.string().optional(),
|
|
15
|
+
origins: z.array(z.string()).optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const storageStateSchema = z.object({
|
|
19
|
+
cookies: z
|
|
20
|
+
.array(
|
|
21
|
+
z.object({
|
|
22
|
+
name: z.string(),
|
|
23
|
+
value: z.string(),
|
|
24
|
+
domain: z.string(),
|
|
25
|
+
path: z.string(),
|
|
26
|
+
expires: z.number(),
|
|
27
|
+
httpOnly: z.boolean(),
|
|
28
|
+
secure: z.boolean(),
|
|
29
|
+
sameSite: z.enum(["Strict", "Lax", "None"]),
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
.default([]),
|
|
33
|
+
origins: z
|
|
34
|
+
.array(
|
|
35
|
+
z.object({
|
|
36
|
+
origin: z.string(),
|
|
37
|
+
localStorage: z
|
|
38
|
+
.array(z.object({ name: z.string(), value: z.string() }))
|
|
39
|
+
.default([]),
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
.default([]),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const profileSchema = z.object({
|
|
46
|
+
_meta: profileMetaSchema.optional(),
|
|
47
|
+
cookies: storageStateSchema.shape.cookies,
|
|
48
|
+
origins: storageStateSchema.shape.origins,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type ProfileMeta = z.infer<typeof profileMetaSchema>;
|
|
52
|
+
export type Profile = z.infer<typeof profileSchema>;
|
|
53
|
+
|
|
54
|
+
export interface ProfileInfo {
|
|
55
|
+
name: string;
|
|
56
|
+
scope: "local" | "local-private" | "global";
|
|
57
|
+
path: string;
|
|
58
|
+
meta?: ProfileMeta;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Path Resolution
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the global profiles directory
|
|
67
|
+
* Uses XDG_CONFIG_HOME or falls back to ~/.config
|
|
68
|
+
*/
|
|
69
|
+
function getGlobalProfilesDir(): string {
|
|
70
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
|
71
|
+
const base = xdgConfig || path.join(os.homedir(), ".config");
|
|
72
|
+
return path.join(base, "agent-browser", "profiles");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get the local profiles directory (project-scoped)
|
|
77
|
+
*/
|
|
78
|
+
function getLocalProfilesDir(cwd?: string): string {
|
|
79
|
+
return path.join(cwd || process.cwd(), ".agent-browser", "profiles");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the local private profiles directory (gitignored)
|
|
84
|
+
*/
|
|
85
|
+
function getLocalPrivateProfilesDir(cwd?: string): string {
|
|
86
|
+
return path.join(getLocalProfilesDir(cwd), ".private");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve profile path by name, checking local first then global
|
|
91
|
+
* Returns null if not found
|
|
92
|
+
*/
|
|
93
|
+
export function resolveProfilePath(
|
|
94
|
+
name: string,
|
|
95
|
+
cwd?: string,
|
|
96
|
+
): { path: string; scope: "local" | "local-private" | "global" } | null {
|
|
97
|
+
// Check local private first
|
|
98
|
+
const localPrivatePath = path.join(
|
|
99
|
+
getLocalPrivateProfilesDir(cwd),
|
|
100
|
+
`${name}.json`,
|
|
101
|
+
);
|
|
102
|
+
if (fs.existsSync(localPrivatePath)) {
|
|
103
|
+
return { path: localPrivatePath, scope: "local-private" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check local
|
|
107
|
+
const localPath = path.join(getLocalProfilesDir(cwd), `${name}.json`);
|
|
108
|
+
if (fs.existsSync(localPath)) {
|
|
109
|
+
return { path: localPath, scope: "local" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check global
|
|
113
|
+
const globalPath = path.join(getGlobalProfilesDir(), `${name}.json`);
|
|
114
|
+
if (fs.existsSync(globalPath)) {
|
|
115
|
+
return { path: globalPath, scope: "global" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get the path where a new profile should be saved
|
|
123
|
+
*/
|
|
124
|
+
export function getProfileSavePath(
|
|
125
|
+
name: string,
|
|
126
|
+
options?: { global?: boolean; private?: boolean; cwd?: string },
|
|
127
|
+
): string {
|
|
128
|
+
if (options?.global) {
|
|
129
|
+
return path.join(getGlobalProfilesDir(), `${name}.json`);
|
|
130
|
+
}
|
|
131
|
+
if (options?.private) {
|
|
132
|
+
return path.join(getLocalPrivateProfilesDir(options?.cwd), `${name}.json`);
|
|
133
|
+
}
|
|
134
|
+
return path.join(getLocalProfilesDir(options?.cwd), `${name}.json`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Profile CRUD Operations
|
|
139
|
+
// ============================================================================
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* List all available profiles (local + global)
|
|
143
|
+
*/
|
|
144
|
+
export function listProfiles(cwd?: string): ProfileInfo[] {
|
|
145
|
+
const profiles: ProfileInfo[] = [];
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
|
|
148
|
+
// Helper to scan a directory
|
|
149
|
+
const scanDir = (
|
|
150
|
+
dir: string,
|
|
151
|
+
scope: "local" | "local-private" | "global",
|
|
152
|
+
) => {
|
|
153
|
+
if (!fs.existsSync(dir)) return;
|
|
154
|
+
const files = fs.readdirSync(dir);
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
if (!file.endsWith(".json")) continue;
|
|
157
|
+
// Skip .private directory when scanning local
|
|
158
|
+
if (file === ".private") continue;
|
|
159
|
+
|
|
160
|
+
const name = file.replace(/\.json$/, "");
|
|
161
|
+
if (seen.has(name)) continue; // Local takes precedence
|
|
162
|
+
seen.add(name);
|
|
163
|
+
|
|
164
|
+
const profilePath = path.join(dir, file);
|
|
165
|
+
try {
|
|
166
|
+
const content = JSON.parse(fs.readFileSync(profilePath, "utf-8"));
|
|
167
|
+
const parsed = profileSchema.safeParse(content);
|
|
168
|
+
profiles.push({
|
|
169
|
+
name,
|
|
170
|
+
scope,
|
|
171
|
+
path: profilePath,
|
|
172
|
+
meta: parsed.success ? parsed.data._meta : undefined,
|
|
173
|
+
});
|
|
174
|
+
} catch {
|
|
175
|
+
// Invalid profile, still list it
|
|
176
|
+
profiles.push({
|
|
177
|
+
name,
|
|
178
|
+
scope,
|
|
179
|
+
path: profilePath,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Scan in order of precedence
|
|
186
|
+
scanDir(getLocalPrivateProfilesDir(cwd), "local-private");
|
|
187
|
+
scanDir(getLocalProfilesDir(cwd), "local");
|
|
188
|
+
scanDir(getGlobalProfilesDir(), "global");
|
|
189
|
+
|
|
190
|
+
return profiles.sort((a, b) => a.name.localeCompare(b.name));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Load a profile by name
|
|
195
|
+
*/
|
|
196
|
+
export function loadProfile(name: string, cwd?: string): Profile | null {
|
|
197
|
+
const resolved = resolveProfilePath(name, cwd);
|
|
198
|
+
if (!resolved) return null;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const content = JSON.parse(fs.readFileSync(resolved.path, "utf-8"));
|
|
202
|
+
const parsed = profileSchema.parse(content);
|
|
203
|
+
return parsed;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get storage state from a profile (strips _meta for Playwright)
|
|
211
|
+
*/
|
|
212
|
+
export function getStorageStateFromProfile(profile: Profile): StorageState {
|
|
213
|
+
return {
|
|
214
|
+
cookies: profile.cookies,
|
|
215
|
+
origins: profile.origins,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Load storage state by profile name
|
|
221
|
+
*/
|
|
222
|
+
export function loadStorageState(
|
|
223
|
+
name: string,
|
|
224
|
+
cwd?: string,
|
|
225
|
+
): StorageState | null {
|
|
226
|
+
const profile = loadProfile(name, cwd);
|
|
227
|
+
if (!profile) return null;
|
|
228
|
+
return getStorageStateFromProfile(profile);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Save a profile
|
|
233
|
+
*/
|
|
234
|
+
export function saveProfile(
|
|
235
|
+
name: string,
|
|
236
|
+
storageState: StorageState,
|
|
237
|
+
options?: {
|
|
238
|
+
global?: boolean;
|
|
239
|
+
private?: boolean;
|
|
240
|
+
cwd?: string;
|
|
241
|
+
description?: string;
|
|
242
|
+
origins?: string[];
|
|
243
|
+
},
|
|
244
|
+
): string {
|
|
245
|
+
const savePath = getProfileSavePath(name, options);
|
|
246
|
+
const dir = path.dirname(savePath);
|
|
247
|
+
|
|
248
|
+
// Ensure directory exists
|
|
249
|
+
if (!fs.existsSync(dir)) {
|
|
250
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// If saving to .private, ensure it's gitignored
|
|
254
|
+
if (options?.private) {
|
|
255
|
+
ensurePrivateDirGitignored(options?.cwd);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check if profile already exists to preserve metadata
|
|
259
|
+
let existingMeta: ProfileMeta | undefined;
|
|
260
|
+
if (fs.existsSync(savePath)) {
|
|
261
|
+
try {
|
|
262
|
+
const existing = JSON.parse(fs.readFileSync(savePath, "utf-8"));
|
|
263
|
+
existingMeta = existing._meta;
|
|
264
|
+
} catch {
|
|
265
|
+
// Ignore
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const profile: Profile = {
|
|
270
|
+
_meta: {
|
|
271
|
+
createdAt: existingMeta?.createdAt || new Date().toISOString(),
|
|
272
|
+
lastUsedAt: new Date().toISOString(),
|
|
273
|
+
description: options?.description || existingMeta?.description,
|
|
274
|
+
origins: options?.origins || existingMeta?.origins,
|
|
275
|
+
},
|
|
276
|
+
cookies: storageState.cookies,
|
|
277
|
+
origins: storageState.origins,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
fs.writeFileSync(savePath, JSON.stringify(profile, null, 2));
|
|
281
|
+
return savePath;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Delete a profile
|
|
286
|
+
*/
|
|
287
|
+
export function deleteProfile(name: string, cwd?: string): boolean {
|
|
288
|
+
const resolved = resolveProfilePath(name, cwd);
|
|
289
|
+
if (!resolved) return false;
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
fs.unlinkSync(resolved.path);
|
|
293
|
+
return true;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Import a profile from an external file
|
|
301
|
+
*/
|
|
302
|
+
export function importProfile(
|
|
303
|
+
name: string,
|
|
304
|
+
sourcePath: string,
|
|
305
|
+
options?: { global?: boolean; private?: boolean; cwd?: string },
|
|
306
|
+
): string {
|
|
307
|
+
const content = fs.readFileSync(sourcePath, "utf-8");
|
|
308
|
+
const parsed = JSON.parse(content);
|
|
309
|
+
|
|
310
|
+
// Validate it's a valid storage state or profile
|
|
311
|
+
const asProfile = profileSchema.safeParse(parsed);
|
|
312
|
+
const asStorageState = storageStateSchema.safeParse(parsed);
|
|
313
|
+
|
|
314
|
+
let storageState: StorageState;
|
|
315
|
+
if (asProfile.success) {
|
|
316
|
+
storageState = getStorageStateFromProfile(asProfile.data);
|
|
317
|
+
} else if (asStorageState.success) {
|
|
318
|
+
storageState = asStorageState.data;
|
|
319
|
+
} else {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Invalid profile format: ${asProfile.error?.message || asStorageState.error?.message}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return saveProfile(name, storageState, options);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Update profile's lastUsedAt timestamp
|
|
330
|
+
*/
|
|
331
|
+
export function touchProfile(name: string, cwd?: string): void {
|
|
332
|
+
const resolved = resolveProfilePath(name, cwd);
|
|
333
|
+
if (!resolved) return;
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const content = JSON.parse(fs.readFileSync(resolved.path, "utf-8"));
|
|
337
|
+
const parsed = profileSchema.parse(content);
|
|
338
|
+
|
|
339
|
+
parsed._meta = {
|
|
340
|
+
...parsed._meta,
|
|
341
|
+
lastUsedAt: new Date().toISOString(),
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
fs.writeFileSync(resolved.path, JSON.stringify(parsed, null, 2));
|
|
345
|
+
} catch {
|
|
346
|
+
// Ignore errors
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// Gitignore Helper
|
|
352
|
+
// ============================================================================
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Ensure .private directory is gitignored
|
|
356
|
+
*/
|
|
357
|
+
function ensurePrivateDirGitignored(cwd?: string): void {
|
|
358
|
+
const agentBrowserDir = path.join(cwd || process.cwd(), ".agent-browser");
|
|
359
|
+
const gitignorePath = path.join(agentBrowserDir, ".gitignore");
|
|
360
|
+
|
|
361
|
+
// Check if .gitignore exists and contains .private
|
|
362
|
+
if (fs.existsSync(gitignorePath)) {
|
|
363
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
364
|
+
if (content.includes("profiles/.private")) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// Append to existing
|
|
368
|
+
fs.appendFileSync(
|
|
369
|
+
gitignorePath,
|
|
370
|
+
"\n# Private profiles (contains auth tokens)\nprofiles/.private/\n",
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
// Create new .gitignore
|
|
374
|
+
if (!fs.existsSync(agentBrowserDir)) {
|
|
375
|
+
fs.mkdirSync(agentBrowserDir, { recursive: true });
|
|
376
|
+
}
|
|
377
|
+
fs.writeFileSync(
|
|
378
|
+
gitignorePath,
|
|
379
|
+
"# Private profiles (contains auth tokens)\nprofiles/.private/\n",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ============================================================================
|
|
385
|
+
// Profile Resolution for Browser Options
|
|
386
|
+
// ============================================================================
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Resolve storage state from profile name or path
|
|
390
|
+
* Returns the storage state to pass to browser context
|
|
391
|
+
*/
|
|
392
|
+
export function resolveStorageStateOption(
|
|
393
|
+
profile?: string,
|
|
394
|
+
storageStatePath?: string,
|
|
395
|
+
cwd?: string,
|
|
396
|
+
): StorageState | string | undefined {
|
|
397
|
+
// Profile takes precedence
|
|
398
|
+
if (profile) {
|
|
399
|
+
const storageState = loadStorageState(profile, cwd);
|
|
400
|
+
if (!storageState) {
|
|
401
|
+
throw new Error(`Profile not found: ${profile}`);
|
|
402
|
+
}
|
|
403
|
+
// Touch the profile to update lastUsedAt
|
|
404
|
+
touchProfile(profile, cwd);
|
|
405
|
+
return storageState;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Fall back to storageStatePath
|
|
409
|
+
if (storageStatePath) {
|
|
410
|
+
return storageStatePath;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|