agent-browser-loop 0.2.2 → 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 +39 -17
- package/package.json +1 -1
- package/src/browser.ts +22 -0
- package/src/cli.ts +451 -7
- package/src/commands.ts +11 -0
- package/src/daemon.ts +53 -8
- package/src/index.ts +15 -0
- package/src/profiles.ts +414 -0
- package/src/server.ts +148 -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,6 +248,8 @@ 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;
|
|
@@ -250,14 +266,34 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
|
250
266
|
return session;
|
|
251
267
|
}
|
|
252
268
|
|
|
253
|
-
async function closeSession(
|
|
269
|
+
async function closeSession(
|
|
270
|
+
sessionId: string,
|
|
271
|
+
): Promise<{ profileSaved?: string }> {
|
|
254
272
|
const session = sessions.get(sessionId);
|
|
255
273
|
if (!session) {
|
|
256
274
|
throw new Error(`Session not found: ${sessionId}`);
|
|
257
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
|
+
|
|
258
292
|
await session.browser.stop();
|
|
259
293
|
sessions.delete(sessionId);
|
|
260
294
|
idGenerator.release(sessionId);
|
|
295
|
+
|
|
296
|
+
return { profileSaved };
|
|
261
297
|
}
|
|
262
298
|
|
|
263
299
|
const shutdown = async () => {
|
|
@@ -398,10 +434,10 @@ async function handleRequest(
|
|
|
398
434
|
sessions: Map<string, DaemonSession>,
|
|
399
435
|
createSession: (
|
|
400
436
|
sessionId?: string,
|
|
401
|
-
options?: AgentBrowserOptions,
|
|
437
|
+
options?: AgentBrowserOptions & { profile?: string; noSave?: boolean },
|
|
402
438
|
) => Promise<DaemonSession>,
|
|
403
439
|
getOrDefaultSession: (sessionId?: string) => DaemonSession,
|
|
404
|
-
closeSession: (sessionId: string) => Promise<
|
|
440
|
+
closeSession: (sessionId: string) => Promise<{ profileSaved?: string }>,
|
|
405
441
|
): Promise<DaemonResponse> {
|
|
406
442
|
const { id } = request;
|
|
407
443
|
|
|
@@ -439,8 +475,12 @@ async function handleRequest(
|
|
|
439
475
|
}
|
|
440
476
|
|
|
441
477
|
case "close": {
|
|
442
|
-
await closeSession(request.sessionId);
|
|
443
|
-
return {
|
|
478
|
+
const { profileSaved } = await closeSession(request.sessionId);
|
|
479
|
+
return {
|
|
480
|
+
id,
|
|
481
|
+
success: true,
|
|
482
|
+
data: { closed: request.sessionId, profileSaved },
|
|
483
|
+
};
|
|
444
484
|
}
|
|
445
485
|
|
|
446
486
|
case "command": {
|
|
@@ -451,7 +491,12 @@ async function handleRequest(
|
|
|
451
491
|
const result = await executeCommand(session.browser, request.command);
|
|
452
492
|
// Handle close command - close the session
|
|
453
493
|
if (request.command.type === "close") {
|
|
454
|
-
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
|
+
};
|
|
455
500
|
}
|
|
456
501
|
return { id, success: true, data: result };
|
|
457
502
|
} finally {
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,21 @@ 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";
|
|
38
53
|
export type { ElementSelectors, StoredElementRef } from "./ref-store";
|
|
39
54
|
// Ref store for server-side element reference management
|
|
40
55
|
export { ElementRefStore } from "./ref-store";
|
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
|
+
}
|