@proagentstore/cli 0.1.7 → 0.1.9

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.
@@ -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 "&amp;";
444
+ case "<": return "&lt;";
445
+ case ">": return "&gt;";
446
+ case '"': return "&quot;";
447
+ default: return "&#39;";
448
+ }
449
+ });
450
+ }