agent-browser-loop 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +461 -33
- package/src/commands.ts +11 -0
- package/src/daemon.ts +170 -8
- package/src/index.ts +19 -0
- package/src/profiles.ts +414 -0
- package/src/server.ts +148 -0
- package/src/version.ts +19 -0
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
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -19,7 +19,16 @@ import {
|
|
|
19
19
|
} from "./commands";
|
|
20
20
|
import { createIdGenerator } from "./id";
|
|
21
21
|
import { log } from "./log";
|
|
22
|
+
import {
|
|
23
|
+
deleteProfile,
|
|
24
|
+
listProfiles,
|
|
25
|
+
loadProfile,
|
|
26
|
+
resolveProfilePath,
|
|
27
|
+
resolveStorageStateOption,
|
|
28
|
+
saveProfile,
|
|
29
|
+
} from "./profiles";
|
|
22
30
|
import { formatStateText } from "./state";
|
|
31
|
+
import type { StorageState } from "./types";
|
|
23
32
|
|
|
24
33
|
export interface BrowserServerConfig {
|
|
25
34
|
host?: string;
|
|
@@ -232,6 +241,7 @@ function getWaitCondition(data: WaitRequest): WaitCondition {
|
|
|
232
241
|
const createSessionBodySchema = z.object({
|
|
233
242
|
headless: z.boolean().optional(),
|
|
234
243
|
userDataDir: z.string().optional(),
|
|
244
|
+
profile: z.string().optional(),
|
|
235
245
|
});
|
|
236
246
|
|
|
237
247
|
const sessionParamsSchema = z.object({
|
|
@@ -528,16 +538,43 @@ const waitRoute = createRoute({
|
|
|
528
538
|
},
|
|
529
539
|
});
|
|
530
540
|
|
|
541
|
+
const closeSessionBodySchema = z
|
|
542
|
+
.object({
|
|
543
|
+
saveProfile: z.string().optional(),
|
|
544
|
+
global: z.boolean().optional(),
|
|
545
|
+
private: z.boolean().optional(),
|
|
546
|
+
})
|
|
547
|
+
.optional();
|
|
548
|
+
|
|
531
549
|
const closeRoute = createRoute({
|
|
532
550
|
method: "post",
|
|
533
551
|
path: "/session/{sessionId}/close",
|
|
534
552
|
request: {
|
|
535
553
|
params: sessionParamsSchema,
|
|
554
|
+
body: {
|
|
555
|
+
required: false,
|
|
556
|
+
content: {
|
|
557
|
+
"application/json": {
|
|
558
|
+
schema: closeSessionBodySchema,
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
536
562
|
},
|
|
537
563
|
responses: {
|
|
538
564
|
204: {
|
|
539
565
|
description: "Session closed",
|
|
540
566
|
},
|
|
567
|
+
200: {
|
|
568
|
+
description: "Session closed with profile saved",
|
|
569
|
+
content: {
|
|
570
|
+
"application/json": {
|
|
571
|
+
schema: z.object({
|
|
572
|
+
profileSaved: z.string().optional(),
|
|
573
|
+
profilePath: z.string().optional(),
|
|
574
|
+
}),
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
},
|
|
541
578
|
404: {
|
|
542
579
|
description: "Session not found",
|
|
543
580
|
content: {
|
|
@@ -745,6 +782,14 @@ export function startBrowserServer(config: BrowserServerConfig) {
|
|
|
745
782
|
overrides.userDataDir = body.userDataDir;
|
|
746
783
|
}
|
|
747
784
|
|
|
785
|
+
// Handle profile option
|
|
786
|
+
if (body?.profile) {
|
|
787
|
+
const storageState = resolveStorageStateOption(body.profile);
|
|
788
|
+
if (typeof storageState === "object") {
|
|
789
|
+
overrides.storageState = storageState;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
748
793
|
const id = await createNewSession(overrides);
|
|
749
794
|
return c.json({ sessionId: id }, 200);
|
|
750
795
|
},
|
|
@@ -886,11 +931,29 @@ export function startBrowserServer(config: BrowserServerConfig) {
|
|
|
886
931
|
app.openapi(closeRoute, async (c) => {
|
|
887
932
|
const { sessionId } = c.req.valid("param");
|
|
888
933
|
const session = getSessionOrThrow(sessions, sessionId);
|
|
934
|
+
const body = c.req.valid("json");
|
|
889
935
|
|
|
890
936
|
return withSession(session, async () => {
|
|
937
|
+
let profilePath: string | undefined;
|
|
938
|
+
|
|
939
|
+
// Save profile before closing if requested
|
|
940
|
+
if (body?.saveProfile) {
|
|
941
|
+
const storageState =
|
|
942
|
+
(await session.browser.saveStorageState()) as StorageState;
|
|
943
|
+
profilePath = saveProfile(body.saveProfile, storageState, {
|
|
944
|
+
global: body.global,
|
|
945
|
+
private: body.private,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
891
949
|
await session.browser.stop();
|
|
892
950
|
sessions.delete(sessionId);
|
|
893
951
|
idGenerator.release(sessionId);
|
|
952
|
+
|
|
953
|
+
if (profilePath) {
|
|
954
|
+
return c.json({ profileSaved: body?.saveProfile, profilePath }, 200);
|
|
955
|
+
}
|
|
956
|
+
|
|
894
957
|
return c.body(null, 204);
|
|
895
958
|
});
|
|
896
959
|
});
|
|
@@ -905,6 +968,91 @@ export function startBrowserServer(config: BrowserServerConfig) {
|
|
|
905
968
|
});
|
|
906
969
|
});
|
|
907
970
|
|
|
971
|
+
// ========================================================================
|
|
972
|
+
// Profile Endpoints
|
|
973
|
+
// ========================================================================
|
|
974
|
+
|
|
975
|
+
// GET /profiles - list all profiles
|
|
976
|
+
app.get("/profiles", (c) => {
|
|
977
|
+
const profiles = listProfiles();
|
|
978
|
+
return c.json(profiles);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// GET /profiles/:name - get profile contents
|
|
982
|
+
app.get("/profiles/:name", (c) => {
|
|
983
|
+
const name = c.req.param("name");
|
|
984
|
+
const profile = loadProfile(name);
|
|
985
|
+
if (!profile) {
|
|
986
|
+
return c.json({ error: `Profile not found: ${name}` }, 404);
|
|
987
|
+
}
|
|
988
|
+
const resolved = resolveProfilePath(name);
|
|
989
|
+
return c.json({
|
|
990
|
+
name,
|
|
991
|
+
scope: resolved?.scope,
|
|
992
|
+
path: resolved?.path,
|
|
993
|
+
profile,
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// POST /profiles/:name - save profile from session or body
|
|
998
|
+
app.post("/profiles/:name", async (c) => {
|
|
999
|
+
const name = c.req.param("name");
|
|
1000
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1001
|
+
|
|
1002
|
+
let storageState: StorageState;
|
|
1003
|
+
|
|
1004
|
+
// If sessionId provided, get storage state from that session
|
|
1005
|
+
if (body.sessionId) {
|
|
1006
|
+
const session = sessions.get(body.sessionId);
|
|
1007
|
+
if (!session) {
|
|
1008
|
+
return c.json({ error: `Session not found: ${body.sessionId}` }, 404);
|
|
1009
|
+
}
|
|
1010
|
+
storageState = (await session.browser.saveStorageState()) as StorageState;
|
|
1011
|
+
} else if (body.cookies || body.origins) {
|
|
1012
|
+
// Direct storage state in body
|
|
1013
|
+
storageState = {
|
|
1014
|
+
cookies: body.cookies || [],
|
|
1015
|
+
origins: body.origins || [],
|
|
1016
|
+
};
|
|
1017
|
+
} else {
|
|
1018
|
+
return c.json(
|
|
1019
|
+
{
|
|
1020
|
+
error: "Either sessionId or storage state (cookies/origins) required",
|
|
1021
|
+
},
|
|
1022
|
+
400,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const savedPath = saveProfile(name, storageState, {
|
|
1027
|
+
global: body.global,
|
|
1028
|
+
private: body.private,
|
|
1029
|
+
description: body.description,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
return c.json({
|
|
1033
|
+
name,
|
|
1034
|
+
path: savedPath,
|
|
1035
|
+
cookies: storageState.cookies.length,
|
|
1036
|
+
origins: storageState.origins.length,
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// DELETE /profiles/:name - delete profile
|
|
1041
|
+
app.delete("/profiles/:name", (c) => {
|
|
1042
|
+
const name = c.req.param("name");
|
|
1043
|
+
const resolved = resolveProfilePath(name);
|
|
1044
|
+
if (!resolved) {
|
|
1045
|
+
return c.json({ error: `Profile not found: ${name}` }, 404);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const deleted = deleteProfile(name);
|
|
1049
|
+
if (!deleted) {
|
|
1050
|
+
return c.json({ error: `Failed to delete profile: ${name}` }, 500);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return c.json({ deleted: name, path: resolved.path });
|
|
1054
|
+
});
|
|
1055
|
+
|
|
908
1056
|
const server = Bun.serve({
|
|
909
1057
|
hostname: host,
|
|
910
1058
|
port,
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Version is read from package.json at build/runtime
|
|
2
|
+
// This provides a single source of truth for the package version
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
function loadVersion(): string {
|
|
8
|
+
try {
|
|
9
|
+
// Try to read from package.json relative to this file
|
|
10
|
+
const packagePath = join(dirname(import.meta.dirname), "package.json");
|
|
11
|
+
const pkg = JSON.parse(readFileSync(packagePath, "utf-8"));
|
|
12
|
+
return pkg.version;
|
|
13
|
+
} catch {
|
|
14
|
+
// Fallback if package.json can't be read
|
|
15
|
+
return "0.0.0";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const VERSION = loadVersion();
|