@proagentstore/cli 0.1.8 → 0.1.10
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.
|
@@ -11,7 +11,7 @@ const CAPABILITIES = [
|
|
|
11
11
|
"file.upload",
|
|
12
12
|
"human.approval",
|
|
13
13
|
];
|
|
14
|
-
const APPROVAL_REQUIRED_TASKS = new Set(["browser.open", "job.apply_basic"]);
|
|
14
|
+
const APPROVAL_REQUIRED_TASKS = new Set(["browser.open", "job.apply_basic", "job.apply_authenticated"]);
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
export class RunnerInputError extends Error {
|
|
17
17
|
status = 400;
|
|
@@ -34,7 +34,7 @@ export class LocalRunner {
|
|
|
34
34
|
runtimePlane: "fags",
|
|
35
35
|
runnerRole: "tool-executor",
|
|
36
36
|
capabilities: CAPABILITIES,
|
|
37
|
-
taskTypes: ["echo", "browser.open", "job.apply_basic"],
|
|
37
|
+
taskTypes: ["echo", "browser.open", "job.apply_basic", "job.apply_authenticated"],
|
|
38
38
|
approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
|
|
39
39
|
};
|
|
40
40
|
}
|
|
@@ -57,11 +57,8 @@ export class LocalRunner {
|
|
|
57
57
|
updatedAt: now,
|
|
58
58
|
};
|
|
59
59
|
this.store.putTask(task);
|
|
60
|
-
this.
|
|
61
|
-
|
|
62
|
-
type: "task.created",
|
|
63
|
-
message: `Task created: ${task.type}`,
|
|
64
|
-
data: { status: task.status },
|
|
60
|
+
this.addTaskEvent(task, "task.created", `Task created: ${task.type}`, {
|
|
61
|
+
status: task.status,
|
|
65
62
|
});
|
|
66
63
|
if (task.status === "queued")
|
|
67
64
|
void this.runTask(task.id);
|
|
@@ -79,11 +76,7 @@ export class LocalRunner {
|
|
|
79
76
|
approvedAt: task.updatedAt,
|
|
80
77
|
};
|
|
81
78
|
this.store.putTask(task);
|
|
82
|
-
this.
|
|
83
|
-
taskId: task.id,
|
|
84
|
-
type: "task.approved",
|
|
85
|
-
message: `Task approved: ${task.type}`,
|
|
86
|
-
});
|
|
79
|
+
this.addTaskEvent(task, "task.approved", `Task approved: ${task.type}`);
|
|
87
80
|
await this.runTask(task.id);
|
|
88
81
|
return this.requireTask(id);
|
|
89
82
|
}
|
|
@@ -95,11 +88,7 @@ export class LocalRunner {
|
|
|
95
88
|
task.updatedAt = new Date().toISOString();
|
|
96
89
|
task.completedAt = task.updatedAt;
|
|
97
90
|
this.store.putTask(task);
|
|
98
|
-
this.
|
|
99
|
-
taskId: task.id,
|
|
100
|
-
type: "task.cancelled",
|
|
101
|
-
message: `Task cancelled: ${task.type}`,
|
|
102
|
-
});
|
|
91
|
+
this.addTaskEvent(task, "task.cancelled", `Task cancelled: ${task.type}`);
|
|
103
92
|
return task;
|
|
104
93
|
}
|
|
105
94
|
async close() {
|
|
@@ -113,11 +102,7 @@ export class LocalRunner {
|
|
|
113
102
|
task.status = "running";
|
|
114
103
|
task.updatedAt = new Date().toISOString();
|
|
115
104
|
this.store.putTask(task);
|
|
116
|
-
this.
|
|
117
|
-
taskId: task.id,
|
|
118
|
-
type: "task.running",
|
|
119
|
-
message: `Task running: ${task.type}`,
|
|
120
|
-
});
|
|
105
|
+
this.addTaskEvent(task, "task.running", `Task running: ${task.type}`);
|
|
121
106
|
try {
|
|
122
107
|
const output = await this.execute(task);
|
|
123
108
|
task.status = "completed";
|
|
@@ -125,12 +110,7 @@ export class LocalRunner {
|
|
|
125
110
|
task.updatedAt = new Date().toISOString();
|
|
126
111
|
task.completedAt = task.updatedAt;
|
|
127
112
|
this.store.putTask(task);
|
|
128
|
-
this.
|
|
129
|
-
taskId: task.id,
|
|
130
|
-
type: "task.completed",
|
|
131
|
-
message: `Task completed: ${task.type}`,
|
|
132
|
-
data: output,
|
|
133
|
-
});
|
|
113
|
+
this.addTaskEvent(task, "task.completed", `Task completed: ${task.type}`, output);
|
|
134
114
|
}
|
|
135
115
|
catch (error) {
|
|
136
116
|
task.status = "failed";
|
|
@@ -138,13 +118,17 @@ export class LocalRunner {
|
|
|
138
118
|
task.updatedAt = new Date().toISOString();
|
|
139
119
|
task.completedAt = task.updatedAt;
|
|
140
120
|
this.store.putTask(task);
|
|
141
|
-
this.
|
|
142
|
-
taskId: task.id,
|
|
143
|
-
type: "task.failed",
|
|
144
|
-
message: task.error,
|
|
145
|
-
});
|
|
121
|
+
this.addTaskEvent(task, "task.failed", task.error);
|
|
146
122
|
}
|
|
147
123
|
}
|
|
124
|
+
addTaskEvent(task, type, message, data) {
|
|
125
|
+
this.store.addEvent({
|
|
126
|
+
taskId: task.id,
|
|
127
|
+
type,
|
|
128
|
+
message,
|
|
129
|
+
data,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
148
132
|
async execute(task) {
|
|
149
133
|
if (task.type === "echo") {
|
|
150
134
|
return task.input;
|
|
@@ -156,32 +140,59 @@ export class LocalRunner {
|
|
|
156
140
|
}
|
|
157
141
|
const context = await this.getBrowserContext();
|
|
158
142
|
const page = context.pages()[0] || (await context.newPage());
|
|
143
|
+
this.addTaskEvent(task, "browser.goto.started", "Opening browser page", { url });
|
|
159
144
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
160
|
-
|
|
145
|
+
const output = {
|
|
161
146
|
url: page.url(),
|
|
162
147
|
title: await page.title(),
|
|
163
148
|
};
|
|
149
|
+
this.addTaskEvent(task, "browser.goto.completed", "Browser page loaded", output);
|
|
150
|
+
return output;
|
|
164
151
|
}
|
|
165
152
|
if (task.type === "job.apply_basic") {
|
|
166
|
-
return this.applyToBasicJob(task
|
|
153
|
+
return this.applyToBasicJob(task);
|
|
154
|
+
}
|
|
155
|
+
if (task.type === "job.apply_authenticated") {
|
|
156
|
+
return this.applyToAuthenticatedJob(task);
|
|
167
157
|
}
|
|
168
158
|
throw new Error(`Unknown task type: ${task.type}`);
|
|
169
159
|
}
|
|
170
|
-
async applyToBasicJob(
|
|
171
|
-
const job = normalizeJobApplicationInput(input);
|
|
160
|
+
async applyToBasicJob(task) {
|
|
161
|
+
const job = normalizeJobApplicationInput(task.input);
|
|
172
162
|
const context = await this.getBrowserContext();
|
|
173
163
|
const page = context.pages()[0] || (await context.newPage());
|
|
164
|
+
this.addTaskEvent(task, "browser.goto.started", "Opening job application page", {
|
|
165
|
+
url: job.url,
|
|
166
|
+
});
|
|
174
167
|
await page.goto(job.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
168
|
+
this.addTaskEvent(task, "browser.goto.completed", "Job application page loaded", {
|
|
169
|
+
url: page.url(),
|
|
170
|
+
title: await page.title(),
|
|
171
|
+
});
|
|
172
|
+
this.addTaskEvent(task, "job.form.fill.started", "Filling application form", {
|
|
173
|
+
candidate: {
|
|
174
|
+
fullName: job.candidate.fullName,
|
|
175
|
+
email: job.candidate.email,
|
|
176
|
+
},
|
|
177
|
+
resumeFile: basename(job.resumePath),
|
|
178
|
+
});
|
|
175
179
|
const filledFields = await fillBasicApplicationForm(page, job);
|
|
180
|
+
this.addTaskEvent(task, "job.form.filled", "Application form fields completed", {
|
|
181
|
+
fieldsFilled: filledFields,
|
|
182
|
+
resumeFile: basename(job.resumePath),
|
|
183
|
+
});
|
|
176
184
|
const beforeSubmitUrl = page.url();
|
|
177
185
|
const navigation = page.waitForNavigation({
|
|
178
186
|
waitUntil: "domcontentloaded",
|
|
179
187
|
timeout: 15_000,
|
|
180
188
|
}).catch(() => null);
|
|
189
|
+
this.addTaskEvent(task, "job.form.submit.started", "Submitting application form", {
|
|
190
|
+
url: beforeSubmitUrl,
|
|
191
|
+
});
|
|
181
192
|
await submitApplicationForm(page);
|
|
182
193
|
await navigation;
|
|
183
194
|
await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
|
|
184
|
-
|
|
195
|
+
const output = {
|
|
185
196
|
taskType: "job.apply_basic",
|
|
186
197
|
submitted: true,
|
|
187
198
|
beforeSubmitUrl,
|
|
@@ -191,6 +202,114 @@ export class LocalRunner {
|
|
|
191
202
|
resumeFile: basename(job.resumePath),
|
|
192
203
|
visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 2_000),
|
|
193
204
|
};
|
|
205
|
+
this.addTaskEvent(task, "job.form.submit.completed", "Application submission completed", output);
|
|
206
|
+
return output;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Apply to a job that requires login/registration first.
|
|
210
|
+
* Flow: navigate → detect login requirement → register or login → fill form → submit
|
|
211
|
+
*/
|
|
212
|
+
async applyToAuthenticatedJob(task) {
|
|
213
|
+
const input = normalizeAuthenticatedJobInput(task.input);
|
|
214
|
+
const context = await this.getBrowserContext();
|
|
215
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
216
|
+
// Step 1: Navigate to job page
|
|
217
|
+
this.addTaskEvent(task, "browser.goto.started", "Opening job page", { url: input.url });
|
|
218
|
+
await page.goto(input.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
219
|
+
this.addTaskEvent(task, "browser.goto.completed", "Job page loaded", {
|
|
220
|
+
url: page.url(),
|
|
221
|
+
title: await page.title(),
|
|
222
|
+
});
|
|
223
|
+
// Step 2: Check if we need to authenticate
|
|
224
|
+
const needsAuth = await detectAuthRequirement(page);
|
|
225
|
+
if (needsAuth) {
|
|
226
|
+
this.addTaskEvent(task, "job.auth.required", "Authentication required", {
|
|
227
|
+
loginDetected: true,
|
|
228
|
+
});
|
|
229
|
+
// Navigate to login page
|
|
230
|
+
const loginUrl = input.loginUrl || await findLoginUrl(page, input.url);
|
|
231
|
+
if (!loginUrl)
|
|
232
|
+
throw new Error("Login required but no login URL found");
|
|
233
|
+
await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
234
|
+
if (input.registration && !input.accountExists) {
|
|
235
|
+
// Register first
|
|
236
|
+
this.addTaskEvent(task, "job.auth.register.started", "Registering new account");
|
|
237
|
+
const registerUrl = await findRegisterUrl(page, loginUrl);
|
|
238
|
+
if (registerUrl && registerUrl !== loginUrl) {
|
|
239
|
+
await page.goto(registerUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
240
|
+
}
|
|
241
|
+
await fillRegistrationForm(page, input.registration);
|
|
242
|
+
await submitForm(page);
|
|
243
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
|
|
244
|
+
this.addTaskEvent(task, "job.auth.register.completed", "Registration submitted", {
|
|
245
|
+
url: page.url(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
else if (input.credentials) {
|
|
249
|
+
// Login with existing credentials
|
|
250
|
+
this.addTaskEvent(task, "job.auth.login.started", "Logging in");
|
|
251
|
+
await fillLoginForm(page, input.credentials);
|
|
252
|
+
await submitForm(page);
|
|
253
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
|
|
254
|
+
this.addTaskEvent(task, "job.auth.login.completed", "Login submitted", {
|
|
255
|
+
url: page.url(),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
throw new Error("Login required but no credentials or registration details provided");
|
|
260
|
+
}
|
|
261
|
+
// Verify we're logged in (check for common indicators)
|
|
262
|
+
const stillNeedsAuth = await detectAuthRequirement(page);
|
|
263
|
+
if (stillNeedsAuth) {
|
|
264
|
+
// Try navigating back to the apply page — some sites redirect after login
|
|
265
|
+
const applyUrl = input.url.includes("/apply") ? input.url : `${input.url}`;
|
|
266
|
+
await page.goto(applyUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
267
|
+
const thirdCheck = await detectAuthRequirement(page);
|
|
268
|
+
if (thirdCheck) {
|
|
269
|
+
throw new Error("Authentication failed — still seeing login/register prompts after login attempt");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Navigate to apply page (may have been redirected after login)
|
|
273
|
+
if (!page.url().includes("apply")) {
|
|
274
|
+
await page.goto(input.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Step 3: Look for the apply form (might be on current page or linked)
|
|
278
|
+
const applyLink = await page.locator('a[href*="apply"], a:has-text("Apply")').first();
|
|
279
|
+
if (await applyLink.count() > 0) {
|
|
280
|
+
await applyLink.click();
|
|
281
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
|
|
282
|
+
}
|
|
283
|
+
// Step 4: Fill the application form
|
|
284
|
+
this.addTaskEvent(task, "job.form.fill.started", "Filling application form");
|
|
285
|
+
const filledFields = await fillBasicApplicationForm(page, input);
|
|
286
|
+
this.addTaskEvent(task, "job.form.filled", "Form fields completed", {
|
|
287
|
+
fieldsFilled: filledFields,
|
|
288
|
+
resumeFile: basename(input.resumePath),
|
|
289
|
+
});
|
|
290
|
+
// Step 5: Submit
|
|
291
|
+
const beforeSubmitUrl = page.url();
|
|
292
|
+
const navigation = page.waitForNavigation({
|
|
293
|
+
waitUntil: "domcontentloaded",
|
|
294
|
+
timeout: 15_000,
|
|
295
|
+
}).catch(() => null);
|
|
296
|
+
this.addTaskEvent(task, "job.form.submit.started", "Submitting application");
|
|
297
|
+
await submitApplicationForm(page);
|
|
298
|
+
await navigation;
|
|
299
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
|
|
300
|
+
const output = {
|
|
301
|
+
taskType: "job.apply_authenticated",
|
|
302
|
+
submitted: true,
|
|
303
|
+
authenticated: needsAuth,
|
|
304
|
+
beforeSubmitUrl,
|
|
305
|
+
finalUrl: page.url(),
|
|
306
|
+
title: await page.title(),
|
|
307
|
+
fieldsFilled: filledFields,
|
|
308
|
+
resumeFile: basename(input.resumePath),
|
|
309
|
+
visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 2_000),
|
|
310
|
+
};
|
|
311
|
+
this.addTaskEvent(task, "job.form.submit.completed", "Application submitted", output);
|
|
312
|
+
return output;
|
|
194
313
|
}
|
|
195
314
|
requireTask(id) {
|
|
196
315
|
const task = this.store.getTask(id);
|
|
@@ -406,3 +525,99 @@ function normalizeCreateTaskRequest(request) {
|
|
|
406
525
|
export function fileUrl(path) {
|
|
407
526
|
return pathToFileURL(path).toString();
|
|
408
527
|
}
|
|
528
|
+
// ── Authenticated job application helpers ─────────────────────────────────
|
|
529
|
+
export function normalizeAuthenticatedJobInput(input) {
|
|
530
|
+
const base = normalizeJobApplicationInput(input);
|
|
531
|
+
const credentials = isRecord(input.credentials) ? input.credentials : undefined;
|
|
532
|
+
const registration = isRecord(input.registration) ? input.registration : undefined;
|
|
533
|
+
return {
|
|
534
|
+
...base,
|
|
535
|
+
credentials: credentials ? {
|
|
536
|
+
email: stringValue(credentials.email),
|
|
537
|
+
password: stringValue(credentials.password),
|
|
538
|
+
} : undefined,
|
|
539
|
+
loginUrl: optionalString(input.loginUrl),
|
|
540
|
+
accountExists: input.accountExists === true,
|
|
541
|
+
registration: registration ? {
|
|
542
|
+
fullName: stringValue(registration.fullName),
|
|
543
|
+
email: stringValue(registration.email),
|
|
544
|
+
password: stringValue(registration.password),
|
|
545
|
+
} : undefined,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
async function detectAuthRequirement(page) {
|
|
549
|
+
const body = await page.locator("body").innerHTML({ timeout: 5_000 }).catch(() => "");
|
|
550
|
+
const lower = body.toLowerCase();
|
|
551
|
+
// Check for login/register prompts
|
|
552
|
+
const hasLoginForm = await page.locator('input[type="password"], form[action*="login"], form[action*="signin"]').count() > 0;
|
|
553
|
+
const hasLoginText = /sign\s*in|log\s*in|create\s*(an\s*)?account|register\s*to\s*apply/i.test(lower);
|
|
554
|
+
const hasLoginLink = await page.locator('a[href*="login"], a[href*="signin"], a[href*="register"]').count() > 0;
|
|
555
|
+
return hasLoginForm || (hasLoginText && hasLoginLink);
|
|
556
|
+
}
|
|
557
|
+
async function findLoginUrl(page, baseUrl) {
|
|
558
|
+
const loginLink = page.locator('a[href*="login"], a[href*="signin"], a:has-text("Sign in"), a:has-text("Log in")').first();
|
|
559
|
+
if (await loginLink.count() > 0) {
|
|
560
|
+
const href = await loginLink.getAttribute("href");
|
|
561
|
+
if (href)
|
|
562
|
+
return new URL(href, baseUrl).toString();
|
|
563
|
+
}
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
async function findRegisterUrl(page, baseUrl) {
|
|
567
|
+
const regLink = page.locator('a[href*="register"], a[href*="signup"], a:has-text("Create account"), a:has-text("Sign up")').first();
|
|
568
|
+
if (await regLink.count() > 0) {
|
|
569
|
+
const href = await regLink.getAttribute("href");
|
|
570
|
+
if (href)
|
|
571
|
+
return new URL(href, baseUrl).toString();
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
async function fillLoginForm(page, credentials) {
|
|
576
|
+
await fillFirst(page, [
|
|
577
|
+
'input[name="email"]',
|
|
578
|
+
'input[type="email"]',
|
|
579
|
+
'input[autocomplete="email"]',
|
|
580
|
+
'input[autocomplete="username"]',
|
|
581
|
+
'input[name="username"]',
|
|
582
|
+
], credentials.email);
|
|
583
|
+
await fillFirst(page, [
|
|
584
|
+
'input[name="password"]',
|
|
585
|
+
'input[type="password"]',
|
|
586
|
+
'input[autocomplete="current-password"]',
|
|
587
|
+
], credentials.password);
|
|
588
|
+
}
|
|
589
|
+
async function fillRegistrationForm(page, registration) {
|
|
590
|
+
await fillFirst(page, [
|
|
591
|
+
'input[name="fullName"]',
|
|
592
|
+
'input[name="full_name"]',
|
|
593
|
+
'input[name="name"]',
|
|
594
|
+
'input[autocomplete="name"]',
|
|
595
|
+
], registration.fullName);
|
|
596
|
+
await fillFirst(page, [
|
|
597
|
+
'input[name="email"]',
|
|
598
|
+
'input[type="email"]',
|
|
599
|
+
'input[autocomplete="email"]',
|
|
600
|
+
], registration.email);
|
|
601
|
+
await fillFirst(page, [
|
|
602
|
+
'input[name="password"]',
|
|
603
|
+
'input[type="password"]',
|
|
604
|
+
'input[autocomplete="new-password"]',
|
|
605
|
+
], registration.password);
|
|
606
|
+
}
|
|
607
|
+
async function submitForm(page) {
|
|
608
|
+
const submit = page.locator('button[type="submit"], input[type="submit"]').first();
|
|
609
|
+
if (await submit.count() > 0) {
|
|
610
|
+
await submit.click({ timeout: 10_000 });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const form = page.locator("form").first();
|
|
614
|
+
if (await form.count() > 0) {
|
|
615
|
+
await form.evaluate((node) => {
|
|
616
|
+
const f = node;
|
|
617
|
+
if (typeof f.requestSubmit === "function")
|
|
618
|
+
f.requestSubmit();
|
|
619
|
+
else
|
|
620
|
+
f.submit();
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startTestJobServerAuth } from "./test-job-server-auth.js";
|
|
3
|
+
function arg(name, fallback) {
|
|
4
|
+
const index = process.argv.indexOf(name);
|
|
5
|
+
if (index === -1)
|
|
6
|
+
return fallback;
|
|
7
|
+
return process.argv[index + 1] || fallback;
|
|
8
|
+
}
|
|
9
|
+
function flag(name) {
|
|
10
|
+
return process.argv.includes(name);
|
|
11
|
+
}
|
|
12
|
+
if (flag("--help") || flag("-h")) {
|
|
13
|
+
process.stdout.write(`ProAgentStore test job server (with auth)
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
pags-test-job-server-auth [--port 49211]
|
|
17
|
+
|
|
18
|
+
Routes:
|
|
19
|
+
GET /register Registration form
|
|
20
|
+
POST /register Create account
|
|
21
|
+
GET /login Login form
|
|
22
|
+
POST /login Sign in
|
|
23
|
+
POST /logout Sign out
|
|
24
|
+
GET /jobs/software-engineer Job posting (public)
|
|
25
|
+
GET /apply Application form (requires auth)
|
|
26
|
+
POST /apply Submit application (requires auth)
|
|
27
|
+
GET /dashboard User's applications (requires auth)
|
|
28
|
+
GET /success/:id Submission confirmation (requires auth)
|
|
29
|
+
GET /submissions All submissions (JSON, no auth)
|
|
30
|
+
`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
const port = Number(arg("--port", process.env.PAGS_TEST_JOB_AUTH_PORT || "49211"));
|
|
34
|
+
const server = await startTestJobServerAuth(port);
|
|
35
|
+
process.stdout.write(`PAGS test job server (auth) listening at ${server.url}\n`);
|
|
36
|
+
process.stdout.write(`Job URL: ${server.jobUrl}\n`);
|
|
37
|
+
process.stdout.write(`Register: ${server.registerUrl}\n`);
|
|
38
|
+
process.stdout.write(`Login: ${server.loginUrl}\n`);
|
|
39
|
+
const shutdown = async () => {
|
|
40
|
+
await server.close();
|
|
41
|
+
process.exit(0);
|
|
42
|
+
};
|
|
43
|
+
process.on("SIGINT", () => void shutdown());
|
|
44
|
+
process.on("SIGTERM", () => void shutdown());
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
export async function startTestJobServerAuth(port = 0) {
|
|
3
|
+
const submissions = [];
|
|
4
|
+
const users = [];
|
|
5
|
+
const sessions = new Map();
|
|
6
|
+
const server = createServer(async (req, res) => {
|
|
7
|
+
try {
|
|
8
|
+
await route(req, res, { submissions, users, sessions });
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
html(res, 500, page("Error", `<main><h1>Server Error</h1><pre>${escapeHtml(String(error))}</pre></main>`));
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
await new Promise((resolve) => {
|
|
15
|
+
server.listen(port, "127.0.0.1", resolve);
|
|
16
|
+
});
|
|
17
|
+
const address = server.address();
|
|
18
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
19
|
+
return {
|
|
20
|
+
url,
|
|
21
|
+
jobUrl: `${url}/jobs/software-engineer`,
|
|
22
|
+
loginUrl: `${url}/login`,
|
|
23
|
+
registerUrl: `${url}/register`,
|
|
24
|
+
submissions,
|
|
25
|
+
users,
|
|
26
|
+
sessions,
|
|
27
|
+
async close() {
|
|
28
|
+
await new Promise((resolve, reject) => {
|
|
29
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function route(req, res, state) {
|
|
35
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
36
|
+
const sessionId = parseCookie(req.headers.cookie || "", "session");
|
|
37
|
+
const userId = sessionId ? state.sessions.get(sessionId) : undefined;
|
|
38
|
+
const user = userId ? state.users.find((u) => u.id === userId) : undefined;
|
|
39
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
40
|
+
redirect(res, "/jobs/software-engineer");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (req.method === "GET" && url.pathname === "/register") {
|
|
44
|
+
const error = url.searchParams.get("error") || "";
|
|
45
|
+
html(res, 200, registerPage(error));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (req.method === "POST" && url.pathname === "/register") {
|
|
49
|
+
const body = await parseFormBody(req);
|
|
50
|
+
const email = body.email?.trim().toLowerCase();
|
|
51
|
+
const password = body.password?.trim();
|
|
52
|
+
const fullName = body.fullName?.trim();
|
|
53
|
+
if (!email || !password || !fullName) {
|
|
54
|
+
redirect(res, "/register?error=All+fields+are+required");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (password.length < 6) {
|
|
58
|
+
redirect(res, "/register?error=Password+must+be+at+least+6+characters");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (state.users.some((u) => u.email === email)) {
|
|
62
|
+
redirect(res, "/register?error=Email+already+registered");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const newUser = {
|
|
66
|
+
id: `user_${crypto.randomUUID()}`,
|
|
67
|
+
email,
|
|
68
|
+
password,
|
|
69
|
+
fullName,
|
|
70
|
+
createdAt: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
state.users.push(newUser);
|
|
73
|
+
const newSessionId = `sess_${crypto.randomUUID()}`;
|
|
74
|
+
state.sessions.set(newSessionId, newUser.id);
|
|
75
|
+
setSessionCookie(res, newSessionId);
|
|
76
|
+
redirect(res, "/dashboard");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (req.method === "GET" && url.pathname === "/login") {
|
|
80
|
+
if (user) {
|
|
81
|
+
redirect(res, "/dashboard");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const error = url.searchParams.get("error") || "";
|
|
85
|
+
const next = url.searchParams.get("next") || "";
|
|
86
|
+
html(res, 200, loginPage(error, next));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (req.method === "POST" && url.pathname === "/login") {
|
|
90
|
+
const body = await parseFormBody(req);
|
|
91
|
+
const email = body.email?.trim().toLowerCase();
|
|
92
|
+
const password = body.password?.trim();
|
|
93
|
+
const next = body.next?.trim() || "/dashboard";
|
|
94
|
+
if (!email || !password) {
|
|
95
|
+
redirect(res, `/login?error=Email+and+password+required&next=${encodeURIComponent(next)}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const found = state.users.find((u) => u.email === email && u.password === password);
|
|
99
|
+
if (!found) {
|
|
100
|
+
redirect(res, `/login?error=Invalid+email+or+password&next=${encodeURIComponent(next)}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const newSessionId = `sess_${crypto.randomUUID()}`;
|
|
104
|
+
state.sessions.set(newSessionId, found.id);
|
|
105
|
+
setSessionCookie(res, newSessionId);
|
|
106
|
+
redirect(res, next);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (req.method === "POST" && url.pathname === "/logout") {
|
|
110
|
+
if (sessionId)
|
|
111
|
+
state.sessions.delete(sessionId);
|
|
112
|
+
clearSessionCookie(res);
|
|
113
|
+
redirect(res, "/login");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (req.method === "GET" && url.pathname === "/jobs/software-engineer") {
|
|
117
|
+
html(res, 200, jobPage(user));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (req.method === "GET" && url.pathname === "/apply") {
|
|
121
|
+
if (!user) {
|
|
122
|
+
redirect(res, "/login?next=/apply");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
html(res, 200, applyPage(user));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (req.method === "POST" && url.pathname === "/apply") {
|
|
129
|
+
if (!user) {
|
|
130
|
+
redirect(res, "/login?next=/apply");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const submission = await parseApplication(req, user.id);
|
|
134
|
+
state.submissions.unshift(submission);
|
|
135
|
+
redirect(res, `/success/${submission.id}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (req.method === "GET" && url.pathname === "/dashboard") {
|
|
139
|
+
if (!user) {
|
|
140
|
+
redirect(res, "/login?next=/dashboard");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const userSubmissions = state.submissions.filter((s) => s.userId === user.id);
|
|
144
|
+
html(res, 200, dashboardPage(user, userSubmissions));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (req.method === "GET" && url.pathname.startsWith("/success/")) {
|
|
148
|
+
if (!user) {
|
|
149
|
+
redirect(res, "/login");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const id = url.pathname.split("/").pop() || "";
|
|
153
|
+
const submission = state.submissions.find((row) => row.id === id && row.userId === user.id);
|
|
154
|
+
if (!submission) {
|
|
155
|
+
html(res, 404, page("Not Found", "<main><h1>Application not found</h1></main>"));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
html(res, 200, successPage(submission));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (req.method === "GET" && url.pathname === "/submissions") {
|
|
162
|
+
json(res, 200, { submissions: state.submissions });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
html(res, 404, page("Not Found", "<main><h1>Not found</h1></main>"));
|
|
166
|
+
}
|
|
167
|
+
function registerPage(error) {
|
|
168
|
+
return page("Create Account", `
|
|
169
|
+
<main>
|
|
170
|
+
<section class="auth-card">
|
|
171
|
+
<h1>Create Account</h1>
|
|
172
|
+
<p class="subtitle">Join Fixture Labs to apply for positions</p>
|
|
173
|
+
${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
|
|
174
|
+
<form action="/register" method="post" class="auth-form">
|
|
175
|
+
<label>Full name <input name="fullName" autocomplete="name" required /></label>
|
|
176
|
+
<label>Email <input name="email" type="email" autocomplete="email" required /></label>
|
|
177
|
+
<label>Password <input name="password" type="password" autocomplete="new-password" minlength="6" required /></label>
|
|
178
|
+
<button type="submit">Create account</button>
|
|
179
|
+
</form>
|
|
180
|
+
<p class="switch">Already have an account? <a href="/login">Sign in</a></p>
|
|
181
|
+
</section>
|
|
182
|
+
</main>
|
|
183
|
+
`);
|
|
184
|
+
}
|
|
185
|
+
function loginPage(error, next) {
|
|
186
|
+
return page("Sign In", `
|
|
187
|
+
<main>
|
|
188
|
+
<section class="auth-card">
|
|
189
|
+
<h1>Sign In</h1>
|
|
190
|
+
<p class="subtitle">Sign in to continue your application</p>
|
|
191
|
+
${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
|
|
192
|
+
<form action="/login" method="post" class="auth-form">
|
|
193
|
+
<input type="hidden" name="next" value="${escapeHtml(next || "/dashboard")}" />
|
|
194
|
+
<label>Email <input name="email" type="email" autocomplete="email" required /></label>
|
|
195
|
+
<label>Password <input name="password" type="password" autocomplete="current-password" required /></label>
|
|
196
|
+
<button type="submit">Sign in</button>
|
|
197
|
+
</form>
|
|
198
|
+
<p class="switch">Don't have an account? <a href="/register">Create one</a></p>
|
|
199
|
+
</section>
|
|
200
|
+
</main>
|
|
201
|
+
`);
|
|
202
|
+
}
|
|
203
|
+
function jobPage(user) {
|
|
204
|
+
const nav = user
|
|
205
|
+
? `<nav><span>Signed in as ${escapeHtml(user.fullName)}</span> <a href="/dashboard">Dashboard</a></nav>`
|
|
206
|
+
: `<nav><a href="/login">Sign in</a> <a href="/register">Create account</a></nav>`;
|
|
207
|
+
return page("Senior Software Engineer", `
|
|
208
|
+
${nav}
|
|
209
|
+
<main>
|
|
210
|
+
<section class="job">
|
|
211
|
+
<p class="eyebrow">PAGS Test Fixture</p>
|
|
212
|
+
<h1>Senior Software Engineer</h1>
|
|
213
|
+
<p class="company">Fixture Labs</p>
|
|
214
|
+
<p>Build workflow tools for safe browser agents. This role values TypeScript, automation, product judgment, and careful user approval flows.</p>
|
|
215
|
+
<h2>Requirements</h2>
|
|
216
|
+
<ul>
|
|
217
|
+
<li>5+ years TypeScript/JavaScript experience</li>
|
|
218
|
+
<li>Experience with browser automation (Playwright, Puppeteer)</li>
|
|
219
|
+
<li>Strong product sense and attention to UX details</li>
|
|
220
|
+
<li>Comfortable with distributed systems and Workers</li>
|
|
221
|
+
</ul>
|
|
222
|
+
<h2>Benefits</h2>
|
|
223
|
+
<ul>
|
|
224
|
+
<li>Fully remote</li>
|
|
225
|
+
<li>Competitive compensation</li>
|
|
226
|
+
<li>Health, dental, vision</li>
|
|
227
|
+
</ul>
|
|
228
|
+
<a href="/apply" class="apply-button">Apply for this position</a>
|
|
229
|
+
</section>
|
|
230
|
+
</main>
|
|
231
|
+
`);
|
|
232
|
+
}
|
|
233
|
+
function applyPage(user) {
|
|
234
|
+
return page("Apply - Senior Software Engineer", `
|
|
235
|
+
<nav><span>Signed in as ${escapeHtml(user.fullName)}</span> <a href="/dashboard">Dashboard</a></nav>
|
|
236
|
+
<main>
|
|
237
|
+
<section class="job-header">
|
|
238
|
+
<p class="eyebrow">Applying for</p>
|
|
239
|
+
<h1>Senior Software Engineer</h1>
|
|
240
|
+
<p class="company">Fixture Labs</p>
|
|
241
|
+
</section>
|
|
242
|
+
<form action="/apply" method="post" enctype="multipart/form-data" class="application-form">
|
|
243
|
+
<label>Full name <input name="fullName" autocomplete="name" value="${escapeHtml(user.fullName)}" required /></label>
|
|
244
|
+
<label>Email <input name="email" type="email" autocomplete="email" value="${escapeHtml(user.email)}" required /></label>
|
|
245
|
+
<label>Phone <input name="phone" autocomplete="tel" /></label>
|
|
246
|
+
<label>Location <input name="location" autocomplete="address-level2" /></label>
|
|
247
|
+
<label>LinkedIn <input name="linkedin" type="url" /></label>
|
|
248
|
+
<label>Portfolio <input name="portfolio" type="url" /></label>
|
|
249
|
+
<label>Work authorization
|
|
250
|
+
<select name="workAuthorization" required>
|
|
251
|
+
<option value="">Select one</option>
|
|
252
|
+
<option>Authorized to work in the United States</option>
|
|
253
|
+
<option>Require sponsorship</option>
|
|
254
|
+
</select>
|
|
255
|
+
</label>
|
|
256
|
+
<label>Resume <input name="resume" type="file" accept=".pdf,.txt,.doc,.docx" required /></label>
|
|
257
|
+
<label>Cover note <textarea name="coverNote" rows="5" required></textarea></label>
|
|
258
|
+
<button type="submit">Submit application</button>
|
|
259
|
+
</form>
|
|
260
|
+
</main>
|
|
261
|
+
`);
|
|
262
|
+
}
|
|
263
|
+
function dashboardPage(user, submissions) {
|
|
264
|
+
const rows = submissions.length
|
|
265
|
+
? submissions.map((s) => `
|
|
266
|
+
<tr>
|
|
267
|
+
<td>${escapeHtml(s.id)}</td>
|
|
268
|
+
<td>Senior Software Engineer</td>
|
|
269
|
+
<td>${escapeHtml(s.createdAt.split("T")[0])}</td>
|
|
270
|
+
<td><a href="/success/${escapeHtml(s.id)}">View</a></td>
|
|
271
|
+
</tr>
|
|
272
|
+
`).join("")
|
|
273
|
+
: `<tr><td colspan="4" class="empty">No applications yet. <a href="/jobs/software-engineer">Browse jobs</a></td></tr>`;
|
|
274
|
+
return page("Dashboard", `
|
|
275
|
+
<nav><span>Signed in as ${escapeHtml(user.fullName)}</span>
|
|
276
|
+
<form action="/logout" method="post" style="display:inline"><button type="submit" class="link-button">Sign out</button></form>
|
|
277
|
+
</nav>
|
|
278
|
+
<main>
|
|
279
|
+
<section class="dashboard">
|
|
280
|
+
<h1>My Applications</h1>
|
|
281
|
+
<table>
|
|
282
|
+
<thead><tr><th>ID</th><th>Position</th><th>Date</th><th></th></tr></thead>
|
|
283
|
+
<tbody>${rows}</tbody>
|
|
284
|
+
</table>
|
|
285
|
+
</section>
|
|
286
|
+
</main>
|
|
287
|
+
`);
|
|
288
|
+
}
|
|
289
|
+
function successPage(submission) {
|
|
290
|
+
return page("Application Received", `
|
|
291
|
+
<main>
|
|
292
|
+
<section class="success">
|
|
293
|
+
<p class="eyebrow">Success</p>
|
|
294
|
+
<h1>Application received</h1>
|
|
295
|
+
<p id="application-id">${escapeHtml(submission.id)}</p>
|
|
296
|
+
<p>Thank you, ${escapeHtml(submission.fields.fullName || "candidate")}.</p>
|
|
297
|
+
<p>Resume: ${escapeHtml(submission.resume?.filename || "missing")} (${submission.resume?.size || 0} bytes)</p>
|
|
298
|
+
<a href="/dashboard">Back to dashboard</a>
|
|
299
|
+
</section>
|
|
300
|
+
</main>
|
|
301
|
+
`);
|
|
302
|
+
}
|
|
303
|
+
function page(title, body) {
|
|
304
|
+
return `<!doctype html>
|
|
305
|
+
<html lang="en">
|
|
306
|
+
<head>
|
|
307
|
+
<meta charset="utf-8" />
|
|
308
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
309
|
+
<title>${escapeHtml(title)}</title>
|
|
310
|
+
<style>
|
|
311
|
+
body { margin: 0; font-family: system-ui, sans-serif; background: #f6f8fb; color: #16202a; }
|
|
312
|
+
main { width: min(880px, calc(100% - 32px)); margin: 40px auto; }
|
|
313
|
+
nav { display: flex; align-items: center; gap: 16px; padding: 12px 24px; background: white; border-bottom: 1px solid #d7dee8; }
|
|
314
|
+
nav span { font-weight: 600; margin-right: auto; }
|
|
315
|
+
nav a, .switch a { color: #4f46e5; text-decoration: none; font-weight: 600; }
|
|
316
|
+
.job, .success, .auth-card, .dashboard, .job-header, form.application-form { background: white; border: 1px solid #d7dee8; border-radius: 8px; padding: 24px; margin-bottom: 16px; }
|
|
317
|
+
.auth-card { max-width: 400px; margin: 60px auto; }
|
|
318
|
+
.auth-form, .application-form { display: grid; gap: 16px; margin-top: 16px; }
|
|
319
|
+
label { display: grid; gap: 6px; font-weight: 650; }
|
|
320
|
+
input, select, textarea { box-sizing: border-box; width: 100%; padding: 10px 12px; border: 1px solid #aab6c4; border-radius: 6px; font: inherit; }
|
|
321
|
+
button[type="submit"] { width: fit-content; padding: 10px 16px; border: 0; border-radius: 6px; background: #166534; color: white; font-weight: 700; cursor: pointer; }
|
|
322
|
+
.link-button { background: none; border: none; color: #4f46e5; font-weight: 600; cursor: pointer; font: inherit; padding: 0; }
|
|
323
|
+
.apply-button { display: inline-block; margin-top: 20px; padding: 12px 20px; background: #166534; color: white; border-radius: 6px; text-decoration: none; font-weight: 700; }
|
|
324
|
+
.eyebrow { color: #4f46e5; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; }
|
|
325
|
+
.company { font-weight: 700; }
|
|
326
|
+
.subtitle { color: #566778; }
|
|
327
|
+
.switch { text-align: center; color: #566778; margin-top: 16px; }
|
|
328
|
+
.error { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; padding: 10px 14px; font-weight: 600; }
|
|
329
|
+
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
|
330
|
+
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #e5e9f0; }
|
|
331
|
+
th { font-weight: 700; color: #566778; text-transform: uppercase; font-size: 0.8em; letter-spacing: .04em; }
|
|
332
|
+
.empty { text-align: center; color: #566778; padding: 32px; }
|
|
333
|
+
ul { padding-left: 20px; line-height: 1.8; }
|
|
334
|
+
</style>
|
|
335
|
+
</head>
|
|
336
|
+
<body>${body}</body>
|
|
337
|
+
</html>`;
|
|
338
|
+
}
|
|
339
|
+
async function parseApplication(req, userId) {
|
|
340
|
+
const contentType = req.headers["content-type"] || "";
|
|
341
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
342
|
+
throw new Error("Expected multipart/form-data");
|
|
343
|
+
}
|
|
344
|
+
const boundary = contentType.match(/boundary=([^;]+)/)?.[1];
|
|
345
|
+
if (!boundary)
|
|
346
|
+
throw new Error("Missing multipart boundary");
|
|
347
|
+
const raw = await readBody(req);
|
|
348
|
+
const parsed = parseMultipart(raw, boundary);
|
|
349
|
+
return {
|
|
350
|
+
id: `fixture_app_${crypto.randomUUID()}`,
|
|
351
|
+
userId,
|
|
352
|
+
fields: parsed.fields,
|
|
353
|
+
resume: parsed.files.resume,
|
|
354
|
+
createdAt: new Date().toISOString(),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function parseMultipart(raw, boundary) {
|
|
358
|
+
const fields = {};
|
|
359
|
+
const files = {};
|
|
360
|
+
const delimiter = Buffer.from(`--${boundary}`);
|
|
361
|
+
let offset = 0;
|
|
362
|
+
while (offset < raw.length) {
|
|
363
|
+
const start = raw.indexOf(delimiter, offset);
|
|
364
|
+
if (start === -1)
|
|
365
|
+
break;
|
|
366
|
+
const partStart = start + delimiter.length;
|
|
367
|
+
if (raw.subarray(partStart, partStart + 2).toString() === "--")
|
|
368
|
+
break;
|
|
369
|
+
const headerStart = raw.subarray(partStart, partStart + 2).toString() === "\r\n"
|
|
370
|
+
? partStart + 2
|
|
371
|
+
: partStart;
|
|
372
|
+
const headerEnd = raw.indexOf(Buffer.from("\r\n\r\n"), headerStart);
|
|
373
|
+
if (headerEnd === -1)
|
|
374
|
+
break;
|
|
375
|
+
const next = raw.indexOf(delimiter, headerEnd + 4);
|
|
376
|
+
if (next === -1)
|
|
377
|
+
break;
|
|
378
|
+
const headers = raw.subarray(headerStart, headerEnd).toString("utf-8");
|
|
379
|
+
const body = trimPartBody(raw.subarray(headerEnd + 4, next));
|
|
380
|
+
const name = headers.match(/name="([^"]+)"/)?.[1];
|
|
381
|
+
const filename = headers.match(/filename="([^"]*)"/)?.[1];
|
|
382
|
+
const ct = headers.match(/Content-Type:\s*([^\r\n]+)/i)?.[1] || "application/octet-stream";
|
|
383
|
+
if (name && filename) {
|
|
384
|
+
files[name] = { filename, contentType: ct, size: body.length };
|
|
385
|
+
}
|
|
386
|
+
else if (name) {
|
|
387
|
+
fields[name] = body.toString("utf-8");
|
|
388
|
+
}
|
|
389
|
+
offset = next;
|
|
390
|
+
}
|
|
391
|
+
return { fields, files };
|
|
392
|
+
}
|
|
393
|
+
function trimPartBody(value) {
|
|
394
|
+
if (value.subarray(-2).toString() === "\r\n")
|
|
395
|
+
return value.subarray(0, -2);
|
|
396
|
+
return value;
|
|
397
|
+
}
|
|
398
|
+
async function parseFormBody(req) {
|
|
399
|
+
const raw = await readBody(req);
|
|
400
|
+
const params = new URLSearchParams(raw.toString("utf-8"));
|
|
401
|
+
const result = {};
|
|
402
|
+
for (const [key, value] of params.entries())
|
|
403
|
+
result[key] = value;
|
|
404
|
+
return result;
|
|
405
|
+
}
|
|
406
|
+
async function readBody(req) {
|
|
407
|
+
const chunks = [];
|
|
408
|
+
for await (const chunk of req)
|
|
409
|
+
chunks.push(Buffer.from(chunk));
|
|
410
|
+
return Buffer.concat(chunks);
|
|
411
|
+
}
|
|
412
|
+
function parseCookie(header, name) {
|
|
413
|
+
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
414
|
+
return match ? match[1] : undefined;
|
|
415
|
+
}
|
|
416
|
+
function setSessionCookie(res, sessionId) {
|
|
417
|
+
res.setHeader("Set-Cookie", `session=${sessionId}; Path=/; HttpOnly; SameSite=Lax`);
|
|
418
|
+
}
|
|
419
|
+
function clearSessionCookie(res) {
|
|
420
|
+
res.setHeader("Set-Cookie", "session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
|
421
|
+
}
|
|
422
|
+
function redirect(res, location) {
|
|
423
|
+
res.writeHead(303, { Location: location });
|
|
424
|
+
res.end();
|
|
425
|
+
}
|
|
426
|
+
function html(res, status, body) {
|
|
427
|
+
res.writeHead(status, {
|
|
428
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
429
|
+
"X-Content-Type-Options": "nosniff",
|
|
430
|
+
});
|
|
431
|
+
res.end(body);
|
|
432
|
+
}
|
|
433
|
+
function json(res, status, body) {
|
|
434
|
+
res.writeHead(status, {
|
|
435
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
436
|
+
"X-Content-Type-Options": "nosniff",
|
|
437
|
+
});
|
|
438
|
+
res.end(JSON.stringify(body));
|
|
439
|
+
}
|
|
440
|
+
function escapeHtml(value) {
|
|
441
|
+
return value.replace(/[&<>"']/g, (char) => {
|
|
442
|
+
switch (char) {
|
|
443
|
+
case "&": return "&";
|
|
444
|
+
case "<": return "<";
|
|
445
|
+
case ">": return ">";
|
|
446
|
+
case '"': return """;
|
|
447
|
+
default: return "'";
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
2
8
|
|
|
3
9
|
// src/index.ts
|
|
4
10
|
import { createRequire } from "module";
|
|
5
|
-
import { Command as
|
|
11
|
+
import { Command as Command8 } from "commander";
|
|
6
12
|
|
|
7
13
|
// src/commands/check.ts
|
|
8
14
|
import { existsSync, readFileSync } from "fs";
|
|
@@ -363,9 +369,143 @@ jobs:
|
|
|
363
369
|
writeLine();
|
|
364
370
|
});
|
|
365
371
|
|
|
372
|
+
// src/commands/login.ts
|
|
373
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
374
|
+
import { createServer } from "http";
|
|
375
|
+
import { homedir } from "os";
|
|
376
|
+
import { join as join3 } from "path";
|
|
377
|
+
import { Command as Command3 } from "commander";
|
|
378
|
+
var CONFIG_DIR = join3(homedir(), ".config", "proagentstore");
|
|
379
|
+
var TOKEN_FILE = join3(CONFIG_DIR, "session.json");
|
|
380
|
+
var API_BASE = "https://api.proagentstore.online";
|
|
381
|
+
var FAS_API = "https://api.freeappstore.online";
|
|
382
|
+
function loadSession() {
|
|
383
|
+
try {
|
|
384
|
+
if (!existsSync3(TOKEN_FILE)) return null;
|
|
385
|
+
const data = JSON.parse(readFileSync2(TOKEN_FILE, "utf-8"));
|
|
386
|
+
if (data.expiresAt < Date.now()) {
|
|
387
|
+
writeLine("Session expired. Run: pags login");
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
return data;
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function saveSession(session) {
|
|
396
|
+
mkdirSync2(CONFIG_DIR, { recursive: true });
|
|
397
|
+
writeFileSync2(TOKEN_FILE, JSON.stringify(session, null, 2));
|
|
398
|
+
}
|
|
399
|
+
function requireSession() {
|
|
400
|
+
const session = loadSession();
|
|
401
|
+
if (!session) {
|
|
402
|
+
writeError("Not logged in. Run: pags login");
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
return session;
|
|
406
|
+
}
|
|
407
|
+
var loginCommand = new Command3("login").description("Sign in with Google or GitHub (opens browser)").option("--github", "Use GitHub instead of Google").action(async (opts) => {
|
|
408
|
+
const provider = opts.github ? "github" : "google";
|
|
409
|
+
writeLine(`Opening browser for ${provider} sign-in...`);
|
|
410
|
+
const port = await findFreePort();
|
|
411
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback`;
|
|
412
|
+
const tokenPromise = new Promise((resolve5, reject) => {
|
|
413
|
+
const timeout = setTimeout(() => {
|
|
414
|
+
server.close();
|
|
415
|
+
reject(new Error("Login timed out (60s)"));
|
|
416
|
+
}, 6e4);
|
|
417
|
+
const server = createServer(async (req, res) => {
|
|
418
|
+
const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
|
|
419
|
+
if (url.pathname !== "/callback") {
|
|
420
|
+
res.writeHead(404);
|
|
421
|
+
res.end();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const fasSession = url.searchParams.get("fas_session");
|
|
425
|
+
if (!fasSession) {
|
|
426
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
427
|
+
res.end("<h1>Sign-in failed</h1><p>No session received.</p>");
|
|
428
|
+
clearTimeout(timeout);
|
|
429
|
+
server.close();
|
|
430
|
+
reject(new Error("No fas_session in callback"));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
const exchangeRes = await fetch(`${API_BASE}/v1/auth/exchange`, {
|
|
435
|
+
method: "POST",
|
|
436
|
+
headers: { "Content-Type": "application/json" },
|
|
437
|
+
body: JSON.stringify({ fas_session: fasSession })
|
|
438
|
+
});
|
|
439
|
+
const data = await exchangeRes.json();
|
|
440
|
+
if (!data.token) throw new Error(data.error || "Exchange failed");
|
|
441
|
+
saveSession({
|
|
442
|
+
token: data.token,
|
|
443
|
+
user: data.user,
|
|
444
|
+
expiresAt: Date.now() + 29 * 24 * 60 * 60 * 1e3
|
|
445
|
+
// ~29 days
|
|
446
|
+
});
|
|
447
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
448
|
+
res.end(`<h1>Signed in as ${data.user?.login}</h1><p>You can close this tab and return to the terminal.</p><script>window.close()</script>`);
|
|
449
|
+
clearTimeout(timeout);
|
|
450
|
+
server.close();
|
|
451
|
+
resolve5(data.token);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
454
|
+
res.end(`<h1>Sign-in failed</h1><p>${err instanceof Error ? err.message : String(err)}</p>`);
|
|
455
|
+
clearTimeout(timeout);
|
|
456
|
+
server.close();
|
|
457
|
+
reject(err);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
server.listen(port, "127.0.0.1");
|
|
461
|
+
});
|
|
462
|
+
const oauthBase = provider === "google" ? `${FAS_API}/v1/auth/google/start` : `${FAS_API}/v1/auth/github/start`;
|
|
463
|
+
const oauthUrl = `${oauthBase}?app_id=pags-cli&response_mode=query&return_to=${encodeURIComponent(callbackUrl)}`;
|
|
464
|
+
const open = (await import("child_process")).exec;
|
|
465
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
466
|
+
open(`${cmd} "${oauthUrl}"`);
|
|
467
|
+
try {
|
|
468
|
+
await tokenPromise;
|
|
469
|
+
const session = loadSession();
|
|
470
|
+
writeLine(`Signed in as ${session?.user.login}. Token stored at ${TOKEN_FILE}`);
|
|
471
|
+
} catch (err) {
|
|
472
|
+
writeError(err instanceof Error ? err.message : String(err));
|
|
473
|
+
process.exit(1);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
var logoutCommand = new Command3("logout").description("Sign out and remove stored session").action(() => {
|
|
477
|
+
try {
|
|
478
|
+
if (existsSync3(TOKEN_FILE)) {
|
|
479
|
+
const { unlinkSync } = __require("fs");
|
|
480
|
+
unlinkSync(TOKEN_FILE);
|
|
481
|
+
}
|
|
482
|
+
writeLine("Signed out.");
|
|
483
|
+
} catch {
|
|
484
|
+
writeLine("Already signed out.");
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
var whoamiCommand = new Command3("whoami").description("Show current signed-in user").action(() => {
|
|
488
|
+
const session = loadSession();
|
|
489
|
+
if (session) {
|
|
490
|
+
writeLine(`${session.user.login} (${session.user.id})`);
|
|
491
|
+
} else {
|
|
492
|
+
writeLine("Not logged in. Run: pags login");
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
async function findFreePort() {
|
|
496
|
+
return new Promise((resolve5) => {
|
|
497
|
+
const server = createServer();
|
|
498
|
+
server.listen(0, "127.0.0.1", () => {
|
|
499
|
+
const addr = server.address();
|
|
500
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
501
|
+
server.close(() => resolve5(port));
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
366
506
|
// src/commands/mcp.ts
|
|
367
507
|
import { spawn } from "child_process";
|
|
368
|
-
import { Command as
|
|
508
|
+
import { Command as Command4 } from "commander";
|
|
369
509
|
var DEFAULT_MCP_URL = "https://mcp.proagentstore.online/mcp";
|
|
370
510
|
function buildMcpRemoteArgs(opts, extraArgs = []) {
|
|
371
511
|
return ["-y", "mcp-remote", opts.url || DEFAULT_MCP_URL, ...extraArgs];
|
|
@@ -383,23 +523,23 @@ async function runMcpProxy(opts, extraArgs = []) {
|
|
|
383
523
|
});
|
|
384
524
|
});
|
|
385
525
|
}
|
|
386
|
-
var mcpCommand = new
|
|
526
|
+
var mcpCommand = new Command4("mcp").description("Run a local stdio proxy for the official ProAgentStore MCP server").option("--url <url>", "Remote MCP endpoint", DEFAULT_MCP_URL).argument("[args...]", "Extra arguments passed to mcp-remote").action(async (args, opts) => {
|
|
387
527
|
await runMcpProxy(opts, args);
|
|
388
528
|
});
|
|
389
529
|
|
|
390
530
|
// src/commands/publish.ts
|
|
391
531
|
import { execFileSync } from "child_process";
|
|
392
|
-
import { existsSync as
|
|
393
|
-
import { join as
|
|
394
|
-
import { Command as
|
|
395
|
-
var publishCommand = new
|
|
532
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
533
|
+
import { join as join4, resolve as resolve3 } from "path";
|
|
534
|
+
import { Command as Command5 } from "commander";
|
|
535
|
+
var publishCommand = new Command5("publish").description("Publish an agent to ProAgentStore").option("-d, --dir <path>", "Agent directory", ".").action(async (opts) => {
|
|
396
536
|
const dir = resolve3(opts.dir);
|
|
397
|
-
const manifestPath =
|
|
398
|
-
if (!
|
|
537
|
+
const manifestPath = join4(dir, "agent.json");
|
|
538
|
+
if (!existsSync4(manifestPath)) {
|
|
399
539
|
writeError("No agent.json found. Run `pags init` first.");
|
|
400
540
|
process.exit(1);
|
|
401
541
|
}
|
|
402
|
-
const manifest = JSON.parse(
|
|
542
|
+
const manifest = JSON.parse(readFileSync3(manifestPath, "utf-8"));
|
|
403
543
|
const slug = manifest.id;
|
|
404
544
|
if (!slug) {
|
|
405
545
|
writeError("agent.json missing id");
|
|
@@ -473,10 +613,10 @@ var publishCommand = new Command4("publish").description("Publish an agent to Pr
|
|
|
473
613
|
// src/commands/runner.ts
|
|
474
614
|
import { spawn as spawn2 } from "child_process";
|
|
475
615
|
import { randomUUID } from "crypto";
|
|
476
|
-
import { existsSync as
|
|
616
|
+
import { existsSync as existsSync5 } from "fs";
|
|
477
617
|
import { resolve as resolve4 } from "path";
|
|
478
618
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
479
|
-
import { Command as
|
|
619
|
+
import { Command as Command6 } from "commander";
|
|
480
620
|
var runnerCommand = createRunnerCommand();
|
|
481
621
|
function runnerBaseUrl(url) {
|
|
482
622
|
return (clean(url) || clean(process.env.PAGS_RUNNER_URL) || "http://127.0.0.1:49171").replace(/\/$/, "");
|
|
@@ -530,7 +670,7 @@ function clean(value) {
|
|
|
530
670
|
function findWorkspaceRoot() {
|
|
531
671
|
let dir = process.cwd();
|
|
532
672
|
for (let i = 0; i < 8; i++) {
|
|
533
|
-
if (
|
|
673
|
+
if (existsSync5(resolve4(dir, "pnpm-workspace.yaml"))) return dir;
|
|
534
674
|
const parent = resolve4(dir, "..");
|
|
535
675
|
if (parent === dir) break;
|
|
536
676
|
dir = parent;
|
|
@@ -548,10 +688,10 @@ function runnerSpawnSpec(opts) {
|
|
|
548
688
|
let cwd = root;
|
|
549
689
|
let command = "pags-browser-runner";
|
|
550
690
|
let args = runnerArgs;
|
|
551
|
-
if (
|
|
691
|
+
if (existsSync5(localPackage)) {
|
|
552
692
|
command = "pnpm";
|
|
553
693
|
args = ["--filter", "@proagentstore/browser-runner", "dev", "--", ...runnerArgs];
|
|
554
|
-
} else if (
|
|
694
|
+
} else if (existsSync5(bundledPackage)) {
|
|
555
695
|
cwd = process.cwd();
|
|
556
696
|
command = process.execPath;
|
|
557
697
|
args = [bundledPackage, ...runnerArgs];
|
|
@@ -640,7 +780,7 @@ function collectCapability(value, previous = []) {
|
|
|
640
780
|
return [...previous, value];
|
|
641
781
|
}
|
|
642
782
|
function createRunnerCommand() {
|
|
643
|
-
const command = new
|
|
783
|
+
const command = new Command6("runner").description(
|
|
644
784
|
"Manage the local FAGS browser runtime for ProAgentStore agents"
|
|
645
785
|
);
|
|
646
786
|
command.command("start").description("Start the local FAGS browser runtime in the foreground").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Require this bearer token").option("--instance-id <id>", "Bind runner requests to a PAGS instance id").option("--headless", "Run Playwright headless").action(async (opts) => {
|
|
@@ -850,13 +990,67 @@ function createRunnerCommand() {
|
|
|
850
990
|
return command;
|
|
851
991
|
}
|
|
852
992
|
|
|
993
|
+
// src/commands/up.ts
|
|
994
|
+
import { Command as Command7 } from "commander";
|
|
995
|
+
var API_BASE2 = "https://api.proagentstore.online";
|
|
996
|
+
var upCommand = new Command7("up").description("Start the browser runner for all your agent instances").option("--headless", "Run browser in headless mode").option("--instance <id>", "Connect to a specific instance only").action(async (opts) => {
|
|
997
|
+
const session = requireSession();
|
|
998
|
+
writeLine(`Signed in as ${session.user.login}`);
|
|
999
|
+
writeLine("Fetching your instances...");
|
|
1000
|
+
const res = await fetch(`${API_BASE2}/v1/instances/my/instances`, {
|
|
1001
|
+
headers: { Authorization: `Bearer ${session.token}` }
|
|
1002
|
+
});
|
|
1003
|
+
if (!res.ok) {
|
|
1004
|
+
writeError(`Failed to fetch instances: ${res.status}`);
|
|
1005
|
+
process.exit(1);
|
|
1006
|
+
}
|
|
1007
|
+
const data = await res.json();
|
|
1008
|
+
let instances = (data.instances || []).filter((i) => i.status === "active");
|
|
1009
|
+
if (opts.instance) {
|
|
1010
|
+
instances = instances.filter((i) => i.id === opts.instance || i.slug === opts.instance);
|
|
1011
|
+
}
|
|
1012
|
+
if (instances.length === 0) {
|
|
1013
|
+
writeError("No active instances found. Subscribe to an agent first at https://proagentstore.online");
|
|
1014
|
+
process.exit(1);
|
|
1015
|
+
}
|
|
1016
|
+
writeLine(`Found ${instances.length} instance${instances.length === 1 ? "" : "s"}:`);
|
|
1017
|
+
for (const inst of instances) {
|
|
1018
|
+
writeLine(` ${inst.name || inst.slug || inst.id.slice(0, 8)} (${inst.id.slice(0, 8)}...)`);
|
|
1019
|
+
}
|
|
1020
|
+
const target = instances[0];
|
|
1021
|
+
writeLine(`
|
|
1022
|
+
Connecting runner to: ${target.name || target.id.slice(0, 8)}...`);
|
|
1023
|
+
const { spawn: spawn3 } = await import("child_process");
|
|
1024
|
+
const cliPath = process.argv[1];
|
|
1025
|
+
const args = [
|
|
1026
|
+
cliPath,
|
|
1027
|
+
"runner",
|
|
1028
|
+
"connect",
|
|
1029
|
+
target.id,
|
|
1030
|
+
"--pags-token",
|
|
1031
|
+
session.token
|
|
1032
|
+
];
|
|
1033
|
+
if (opts.headless) args.push("--headless");
|
|
1034
|
+
const child = spawn3(process.execPath, args, {
|
|
1035
|
+
stdio: "inherit",
|
|
1036
|
+
env: process.env
|
|
1037
|
+
});
|
|
1038
|
+
child.on("exit", (code) => {
|
|
1039
|
+
process.exit(code || 0);
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
|
|
853
1043
|
// src/index.ts
|
|
854
1044
|
var require2 = createRequire(import.meta.url);
|
|
855
1045
|
var { version } = require2("../package.json");
|
|
856
|
-
var program = new
|
|
1046
|
+
var program = new Command8();
|
|
857
1047
|
program.name("pags").description(
|
|
858
1048
|
"ProAgentStore CLI \u2014 create and publish server-powered AI agents"
|
|
859
1049
|
).version(version);
|
|
1050
|
+
program.addCommand(loginCommand);
|
|
1051
|
+
program.addCommand(logoutCommand);
|
|
1052
|
+
program.addCommand(whoamiCommand);
|
|
1053
|
+
program.addCommand(upCommand);
|
|
860
1054
|
program.addCommand(initCommand);
|
|
861
1055
|
program.addCommand(checkCommand);
|
|
862
1056
|
program.addCommand(publishCommand);
|