@profullstack/agenticjobs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/bin/agenticjobs-mcp.mjs +2 -0
- package/bin/agenticjobs.mjs +2 -0
- package/dist/cli/args.d.ts +17 -0
- package/dist/cli/args.js +103 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -0
- package/dist/cli/format.js +24 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +13 -0
- package/dist/cli/index.js +620 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/jobfile.d.ts +15 -0
- package/dist/cli/jobfile.js +94 -0
- package/dist/cli/jobfile.js.map +1 -0
- package/dist/client/client.d.ts +105 -0
- package/dist/client/client.js +182 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/config.d.ts +33 -0
- package/dist/client/config.js +92 -0
- package/dist/client/config.js.map +1 -0
- package/dist/client/fanout.d.ts +35 -0
- package/dist/client/fanout.js +77 -0
- package/dist/client/fanout.js.map +1 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/login.d.ts +23 -0
- package/dist/client/login.js +45 -0
- package/dist/client/login.js.map +1 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/core/applications.d.ts +67 -0
- package/dist/core/applications.js +187 -0
- package/dist/core/applications.js.map +1 -0
- package/dist/core/auth.d.ts +120 -0
- package/dist/core/auth.js +246 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/import.d.ts +52 -0
- package/dist/core/import.js +311 -0
- package/dist/core/import.js.map +1 -0
- package/dist/core/jobs.d.ts +91 -0
- package/dist/core/jobs.js +381 -0
- package/dist/core/jobs.js.map +1 -0
- package/dist/core/orgs.d.ts +21 -0
- package/dist/core/orgs.js +111 -0
- package/dist/core/orgs.js.map +1 -0
- package/dist/core/resumes.d.ts +51 -0
- package/dist/core/resumes.js +144 -0
- package/dist/core/resumes.js.map +1 -0
- package/dist/core/zip.d.ts +17 -0
- package/dist/core/zip.js +77 -0
- package/dist/core/zip.js.map +1 -0
- package/dist/db/index.d.ts +3 -0
- package/dist/db/index.js +3 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +15 -0
- package/dist/db/migrate.js +67 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/pool.d.ts +13 -0
- package/dist/db/pool.js +68 -0
- package/dist/db/pool.js.map +1 -0
- package/dist/db/seed.d.ts +9 -0
- package/dist/db/seed.js +120 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/directory/announce.d.ts +32 -0
- package/dist/directory/announce.js +59 -0
- package/dist/directory/announce.js.map +1 -0
- package/dist/directory/federate.d.ts +59 -0
- package/dist/directory/federate.js +126 -0
- package/dist/directory/federate.js.map +1 -0
- package/dist/directory/fetch.d.ts +35 -0
- package/dist/directory/fetch.js +138 -0
- package/dist/directory/fetch.js.map +1 -0
- package/dist/directory/index.d.ts +4 -0
- package/dist/directory/index.js +5 -0
- package/dist/directory/index.js.map +1 -0
- package/dist/directory/registry.d.ts +55 -0
- package/dist/directory/registry.js +155 -0
- package/dist/directory/registry.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/markup/escape.d.ts +20 -0
- package/dist/markup/escape.js +56 -0
- package/dist/markup/escape.js.map +1 -0
- package/dist/markup/markdown.d.ts +35 -0
- package/dist/markup/markdown.js +279 -0
- package/dist/markup/markdown.js.map +1 -0
- package/dist/markup/resume.d.ts +72 -0
- package/dist/markup/resume.js +260 -0
- package/dist/markup/resume.js.map +1 -0
- package/dist/mcp/protocol.d.ts +56 -0
- package/dist/mcp/protocol.js +48 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.d.ts +21 -0
- package/dist/mcp/server.js +73 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/stdio.d.ts +13 -0
- package/dist/mcp/stdio.js +85 -0
- package/dist/mcp/stdio.js.map +1 -0
- package/dist/mcp/tools.d.ts +26 -0
- package/dist/mcp/tools.js +351 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/schema/index.d.ts +5 -0
- package/dist/schema/index.js +6 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/instance.d.ts +85 -0
- package/dist/schema/instance.js +147 -0
- package/dist/schema/instance.js.map +1 -0
- package/dist/schema/job.d.ts +151 -0
- package/dist/schema/job.js +73 -0
- package/dist/schema/job.js.map +1 -0
- package/dist/schema/jsonld.d.ts +18 -0
- package/dist/schema/jsonld.js +120 -0
- package/dist/schema/jsonld.js.map +1 -0
- package/dist/schema/query.d.ts +47 -0
- package/dist/schema/query.js +103 -0
- package/dist/schema/query.js.map +1 -0
- package/dist/schema/text.d.ts +27 -0
- package/dist/schema/text.js +117 -0
- package/dist/schema/text.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.js +57 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/deps.d.ts +20 -0
- package/dist/server/deps.js +2 -0
- package/dist/server/deps.js.map +1 -0
- package/dist/server/middleware.d.ts +39 -0
- package/dist/server/middleware.js +100 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/routes/api.d.ts +20 -0
- package/dist/server/routes/api.js +584 -0
- package/dist/server/routes/api.js.map +1 -0
- package/dist/server/routes/descriptor.d.ts +11 -0
- package/dist/server/routes/descriptor.js +31 -0
- package/dist/server/routes/descriptor.js.map +1 -0
- package/dist/server/routes/discovery.d.ts +12 -0
- package/dist/server/routes/discovery.js +305 -0
- package/dist/server/routes/discovery.js.map +1 -0
- package/dist/server/routes/mcp.d.ts +19 -0
- package/dist/server/routes/mcp.js +85 -0
- package/dist/server/routes/mcp.js.map +1 -0
- package/dist/server/routes/openapi.d.ts +11 -0
- package/dist/server/routes/openapi.js +297 -0
- package/dist/server/routes/openapi.js.map +1 -0
- package/dist/server/routes/pages.d.ts +11 -0
- package/dist/server/routes/pages.js +445 -0
- package/dist/server/routes/pages.js.map +1 -0
- package/dist/server/routes/passkey.d.ts +20 -0
- package/dist/server/routes/passkey.js +198 -0
- package/dist/server/routes/passkey.js.map +1 -0
- package/dist/server/routes/specs.d.ts +10 -0
- package/dist/server/routes/specs.js +42 -0
- package/dist/server/routes/specs.js.map +1 -0
- package/dist/server/serve.d.ts +11 -0
- package/dist/server/serve.js +79 -0
- package/dist/server/serve.js.map +1 -0
- package/dist/tui/index.d.ts +13 -0
- package/dist/tui/index.js +266 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/state.d.ts +63 -0
- package/dist/tui/state.js +136 -0
- package/dist/tui/state.js.map +1 -0
- package/dist/tui/types.d.ts +2 -0
- package/dist/tui/types.js +2 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/tui/views.d.ts +11 -0
- package/dist/tui/views.js +197 -0
- package/dist/tui/views.js.map +1 -0
- package/dist/views/auth.d.ts +21 -0
- package/dist/views/auth.js +5 -0
- package/dist/views/auth.js.map +1 -0
- package/dist/views/docs.d.ts +17 -0
- package/dist/views/docs.js +40 -0
- package/dist/views/docs.js.map +1 -0
- package/dist/views/jobs.d.ts +49 -0
- package/dist/views/jobs.js +44 -0
- package/dist/views/jobs.js.map +1 -0
- package/dist/views/layout.d.ts +58 -0
- package/dist/views/layout.js +36 -0
- package/dist/views/layout.js.map +1 -0
- package/dist/views/me.d.ts +27 -0
- package/dist/views/me.js +6 -0
- package/dist/views/me.js.map +1 -0
- package/dist/views/network.d.ts +21 -0
- package/dist/views/network.js +17 -0
- package/dist/views/network.js.map +1 -0
- package/dist/views/post.d.ts +22 -0
- package/dist/views/post.js +20 -0
- package/dist/views/post.js.map +1 -0
- package/docs/openjob.md +200 -0
- package/docs/openresume.md +143 -0
- package/migrations/0001_init.sql +109 -0
- package/migrations/0002_auth.sql +82 -0
- package/migrations/0003_directory.sql +27 -0
- package/migrations/0004_resumes.sql +63 -0
- package/migrations/0005_application_drafts.sql +20 -0
- package/package.json +92 -0
- package/web/public/app.css +731 -0
- package/web/public/app.js +173 -0
- package/web/public/icon.svg +5 -0
- package/web/public/sw.js +64 -0
- package/web/public/tokens.css +153 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkeys.
|
|
3
|
+
*
|
|
4
|
+
* The second half of the house auth rule (magic link and passkey, never a
|
|
5
|
+
* password). A magic link is what gets you in the first time and what gets you
|
|
6
|
+
* back on a machine you have never used; a passkey is what makes the second
|
|
7
|
+
* visit instant.
|
|
8
|
+
*
|
|
9
|
+
* Registration and sign-in are both discoverable-credential flows, so the
|
|
10
|
+
* sign-in path asks for no email at all: the authenticator already knows which
|
|
11
|
+
* account it holds for this board, and asking a person to type an address
|
|
12
|
+
* before touching their fingerprint reader defeats most of the point.
|
|
13
|
+
*
|
|
14
|
+
* The verification itself is @simplewebauthn/server's. Hand-rolling COSE key
|
|
15
|
+
* parsing and signature verification is exactly the kind of thing that looks
|
|
16
|
+
* finished and is subtly wrong.
|
|
17
|
+
*/
|
|
18
|
+
import { Hono } from 'hono';
|
|
19
|
+
import { setCookie } from 'hono/cookie';
|
|
20
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
|
21
|
+
import { createSession, SESSION_COOKIE, storeChallenge, takeChallenge, } from "../../core/auth.js";
|
|
22
|
+
import { clean } from "../../schema/text.js";
|
|
23
|
+
export function passkeyRoutes() {
|
|
24
|
+
const routes = new Hono();
|
|
25
|
+
/**
|
|
26
|
+
* The relying party is the registrable domain, and the origin is the exact
|
|
27
|
+
* URL. Getting these from PUBLIC_URL rather than from the request is
|
|
28
|
+
* deliberate: a passkey registered against one hostname does not work on
|
|
29
|
+
* another, so the value has to be the one the board tells everyone it is.
|
|
30
|
+
*/
|
|
31
|
+
const identity = (publicUrl) => {
|
|
32
|
+
const url = new URL(publicUrl);
|
|
33
|
+
return { rpID: url.hostname, origin: url.origin };
|
|
34
|
+
};
|
|
35
|
+
// --- adding one to an existing account --------------------------------
|
|
36
|
+
routes.post('/register/start', async (c) => {
|
|
37
|
+
const { pool, config } = c.get('deps');
|
|
38
|
+
const viewer = c.get('viewer');
|
|
39
|
+
if (viewer === null)
|
|
40
|
+
return c.json({ error: { message: 'Sign in first.', code: 'unauthenticated' } }, 401);
|
|
41
|
+
const { rpID } = identity(config.publicUrl);
|
|
42
|
+
const existing = await pool.query(`select credential_id from passkeys where user_id = $1`, [viewer.id]);
|
|
43
|
+
const options = await generateRegistrationOptions({
|
|
44
|
+
rpName: config.boardName,
|
|
45
|
+
rpID,
|
|
46
|
+
userName: viewer.email,
|
|
47
|
+
userDisplayName: viewer.name ?? viewer.email,
|
|
48
|
+
attestationType: 'none',
|
|
49
|
+
// Without this the authenticator happily enrols a second credential for
|
|
50
|
+
// an account that already has one, and the person ends up with a list of
|
|
51
|
+
// identical-looking keys.
|
|
52
|
+
excludeCredentials: existing.rows.map((row) => ({ id: row.credential_id })),
|
|
53
|
+
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
|
|
54
|
+
});
|
|
55
|
+
await storeChallenge(pool, options.challenge, 'register', { userId: viewer.id });
|
|
56
|
+
return c.json(options);
|
|
57
|
+
});
|
|
58
|
+
routes.post('/register/verify', async (c) => {
|
|
59
|
+
const { pool, config } = c.get('deps');
|
|
60
|
+
const viewer = c.get('viewer');
|
|
61
|
+
if (viewer === null)
|
|
62
|
+
return c.json({ error: { message: 'Sign in first.', code: 'unauthenticated' } }, 401);
|
|
63
|
+
const body = (await c.req.json());
|
|
64
|
+
const challenge = typeof body['challenge'] === 'string' ? body['challenge'] : '';
|
|
65
|
+
const stored = await takeChallenge(pool, challenge);
|
|
66
|
+
if (stored === null || stored.userId !== viewer.id) {
|
|
67
|
+
return c.json({ error: { message: 'That challenge has expired. Try again.', code: 'expired' } }, 400);
|
|
68
|
+
}
|
|
69
|
+
const { rpID, origin } = identity(config.publicUrl);
|
|
70
|
+
let verification;
|
|
71
|
+
try {
|
|
72
|
+
verification = await verifyRegistrationResponse({
|
|
73
|
+
response: body,
|
|
74
|
+
expectedChallenge: stored.challenge,
|
|
75
|
+
expectedOrigin: origin,
|
|
76
|
+
expectedRPID: rpID,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return c.json({
|
|
81
|
+
error: {
|
|
82
|
+
message: error instanceof Error ? error.message : 'That passkey was not accepted.',
|
|
83
|
+
code: 'rejected',
|
|
84
|
+
},
|
|
85
|
+
}, 400);
|
|
86
|
+
}
|
|
87
|
+
if (!verification.verified || verification.registrationInfo === undefined) {
|
|
88
|
+
return c.json({ error: { message: 'That passkey was not accepted.', code: 'rejected' } }, 400);
|
|
89
|
+
}
|
|
90
|
+
const credential = verification.registrationInfo.credential;
|
|
91
|
+
await pool.query(`insert into passkeys (user_id, credential_id, public_key, counter, transports, label)
|
|
92
|
+
values ($1, $2, $3, $4, $5, $6)
|
|
93
|
+
on conflict (credential_id) do nothing`, [
|
|
94
|
+
viewer.id,
|
|
95
|
+
credential.id,
|
|
96
|
+
Buffer.from(credential.publicKey).toString('base64url'),
|
|
97
|
+
credential.counter,
|
|
98
|
+
credential.transports ?? [],
|
|
99
|
+
clean(body['label'], 60) || 'passkey',
|
|
100
|
+
]);
|
|
101
|
+
return c.json({ ok: true });
|
|
102
|
+
});
|
|
103
|
+
// --- signing in with one ----------------------------------------------
|
|
104
|
+
routes.post('/challenge', async (c) => {
|
|
105
|
+
const { pool, config } = c.get('deps');
|
|
106
|
+
const { rpID } = identity(config.publicUrl);
|
|
107
|
+
// No allowCredentials and no email: a discoverable credential means the
|
|
108
|
+
// authenticator offers the accounts it holds for this board, so nobody has
|
|
109
|
+
// to identify themselves before proving who they are.
|
|
110
|
+
const options = await generateAuthenticationOptions({
|
|
111
|
+
rpID,
|
|
112
|
+
userVerification: 'preferred',
|
|
113
|
+
});
|
|
114
|
+
await storeChallenge(pool, options.challenge, 'login', {});
|
|
115
|
+
return c.json(options);
|
|
116
|
+
});
|
|
117
|
+
routes.post('/verify', async (c) => {
|
|
118
|
+
const { pool, config } = c.get('deps');
|
|
119
|
+
const body = (await c.req.json());
|
|
120
|
+
const response = body['response'];
|
|
121
|
+
const clientDataJSON = typeof response?.['clientDataJSON'] === 'string' ? response['clientDataJSON'] : '';
|
|
122
|
+
// The challenge is read back out of the signed client data rather than
|
|
123
|
+
// taken from a field the caller controls.
|
|
124
|
+
let challenge = '';
|
|
125
|
+
try {
|
|
126
|
+
const clientData = JSON.parse(Buffer.from(clientDataJSON, 'base64url').toString('utf8'));
|
|
127
|
+
challenge = clientData.challenge ?? '';
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return c.json({ error: { message: 'That sign-in was malformed.', code: 'rejected' } }, 400);
|
|
131
|
+
}
|
|
132
|
+
const stored = await takeChallenge(pool, challenge);
|
|
133
|
+
if (stored === null) {
|
|
134
|
+
return c.json({ error: { message: 'That challenge has expired. Try again.', code: 'expired' } }, 400);
|
|
135
|
+
}
|
|
136
|
+
const credentialId = typeof body['id'] === 'string' ? body['id'] : '';
|
|
137
|
+
const found = await pool.query(`select id, user_id, credential_id, public_key, counter, transports
|
|
138
|
+
from passkeys where credential_id = $1`, [credentialId]);
|
|
139
|
+
const row = found.rows[0];
|
|
140
|
+
if (row === undefined) {
|
|
141
|
+
return c.json({ error: { message: 'This board does not know that passkey.', code: 'unknown' } }, 400);
|
|
142
|
+
}
|
|
143
|
+
const { rpID, origin } = identity(config.publicUrl);
|
|
144
|
+
let verification;
|
|
145
|
+
try {
|
|
146
|
+
verification = await verifyAuthenticationResponse({
|
|
147
|
+
response: body,
|
|
148
|
+
expectedChallenge: stored.challenge,
|
|
149
|
+
expectedOrigin: origin,
|
|
150
|
+
expectedRPID: rpID,
|
|
151
|
+
credential: {
|
|
152
|
+
id: row.credential_id,
|
|
153
|
+
publicKey: new Uint8Array(Buffer.from(row.public_key, 'base64url')),
|
|
154
|
+
counter: Number(row.counter),
|
|
155
|
+
transports: row.transports,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
return c.json({
|
|
161
|
+
error: {
|
|
162
|
+
message: error instanceof Error ? error.message : 'That passkey was not accepted.',
|
|
163
|
+
code: 'rejected',
|
|
164
|
+
},
|
|
165
|
+
}, 400);
|
|
166
|
+
}
|
|
167
|
+
if (!verification.verified) {
|
|
168
|
+
return c.json({ error: { message: 'That passkey was not accepted.', code: 'rejected' } }, 400);
|
|
169
|
+
}
|
|
170
|
+
// A counter that goes backwards means the credential has been cloned. Some
|
|
171
|
+
// authenticators legitimately report 0 forever, so only a real decrease
|
|
172
|
+
// from a non-zero value is treated as a problem.
|
|
173
|
+
const newCounter = verification.authenticationInfo.newCounter;
|
|
174
|
+
if (newCounter !== 0 && newCounter < Number(row.counter)) {
|
|
175
|
+
return c.json({
|
|
176
|
+
error: {
|
|
177
|
+
message: 'That passkey looks like it has been copied. It has been disabled.',
|
|
178
|
+
code: 'cloned',
|
|
179
|
+
},
|
|
180
|
+
}, 400);
|
|
181
|
+
}
|
|
182
|
+
await pool.query(`update passkeys set counter = $2, last_used_at = now() where id = $1`, [
|
|
183
|
+
row.id,
|
|
184
|
+
newCounter,
|
|
185
|
+
]);
|
|
186
|
+
const token = await createSession(pool, row.user_id, { label: 'browser (passkey)' });
|
|
187
|
+
setCookie(c, SESSION_COOKIE, token, {
|
|
188
|
+
path: '/',
|
|
189
|
+
httpOnly: true,
|
|
190
|
+
sameSite: 'Lax',
|
|
191
|
+
secure: config.publicUrl.startsWith('https://'),
|
|
192
|
+
maxAge: 30 * 24 * 60 * 60,
|
|
193
|
+
});
|
|
194
|
+
return c.json({ ok: true });
|
|
195
|
+
});
|
|
196
|
+
return routes;
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=passkey.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"passkey.js","sourceRoot":"","sources":["../../../src/server/routes/passkey.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two conventions this board implements, served from the repository's own
|
|
3
|
+
* docs so the page and the file cannot drift apart.
|
|
4
|
+
*/
|
|
5
|
+
export interface Spec {
|
|
6
|
+
title: string;
|
|
7
|
+
summary: string;
|
|
8
|
+
html: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function readSpec(name: string): Promise<Spec | null>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two conventions this board implements, served from the repository's own
|
|
3
|
+
* docs so the page and the file cannot drift apart.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { renderMarkdown, toPlainText } from "../../markup/markdown.js";
|
|
9
|
+
const TITLES = {
|
|
10
|
+
openresume: 'OpenResume.md',
|
|
11
|
+
openjob: 'OpenJob',
|
|
12
|
+
};
|
|
13
|
+
function docsDir() {
|
|
14
|
+
// dist/server/routes/specs.js -> package root -> docs
|
|
15
|
+
return join(dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))), 'docs');
|
|
16
|
+
}
|
|
17
|
+
const cache = new Map();
|
|
18
|
+
export async function readSpec(name) {
|
|
19
|
+
if (!Object.hasOwn(TITLES, name))
|
|
20
|
+
return null;
|
|
21
|
+
const cached = cache.get(name);
|
|
22
|
+
if (cached !== undefined)
|
|
23
|
+
return cached;
|
|
24
|
+
let source;
|
|
25
|
+
try {
|
|
26
|
+
source = await readFile(join(docsDir(), `${name}.md`), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// The file's own h1 becomes the page heading, so it is dropped from the body
|
|
32
|
+
// rather than rendered twice.
|
|
33
|
+
const body = source.replace(/^#\s+.*\n/, '');
|
|
34
|
+
const spec = {
|
|
35
|
+
title: TITLES[name] ?? name,
|
|
36
|
+
summary: toPlainText(body, 160),
|
|
37
|
+
html: renderMarkdown(body, { headingOffset: 0 }),
|
|
38
|
+
};
|
|
39
|
+
cache.set(name, spec);
|
|
40
|
+
return spec;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=specs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specs.js","sourceRoot":"","sources":["../../../src/server/routes/specs.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAQvE,MAAM,MAAM,GAA2B;IACrC,UAAU,EAAE,eAAe;IAC3B,OAAO,EAAE,SAAS;CACnB,CAAC;AAEF,SAAS,OAAO;IACd,sDAAsD;IACtD,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;AAC1F,CAAC;AAED,MAAM,KAAK,GAAG,IAAI,GAAG,EAAgB,CAAC;AAEtC,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY;IACzC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9C,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC/B,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IAExC,IAAI,MAAc,CAAC;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,GAAG,IAAI,KAAK,CAAC,EAAE,MAAM,CAAC,CAAC;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IAED,6EAA6E;IAC7E,8BAA8B;IAC9B,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;IAC7C,MAAM,IAAI,GAAS;QACjB,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,IAAI;QAC3B,OAAO,EAAE,WAAW,CAAC,IAAI,EAAE,GAAG,CAAC;QAC/B,IAAI,EAAE,cAAc,CAAC,IAAI,EAAE,EAAE,aAAa,EAAE,CAAC,EAAE,CAAC;KACjD,CAAC;IACF,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACtB,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bringing the board up.
|
|
3
|
+
*
|
|
4
|
+
* Migrate, listen, and - only if asked - start announcing to a directory.
|
|
5
|
+
*/
|
|
6
|
+
import { type Config } from '../config.ts';
|
|
7
|
+
export interface Running {
|
|
8
|
+
config: Config;
|
|
9
|
+
stop: () => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function startServer(overrides?: Partial<Config>): Promise<Running>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bringing the board up.
|
|
3
|
+
*
|
|
4
|
+
* Migrate, listen, and - only if asked - start announcing to a directory.
|
|
5
|
+
*/
|
|
6
|
+
import { serve } from '@hono/node-server';
|
|
7
|
+
import { loadConfig } from "../config.js";
|
|
8
|
+
import { getPool } from "../db/pool.js";
|
|
9
|
+
import { migrate } from "../db/migrate.js";
|
|
10
|
+
import { startAnnouncing } from "../directory/announce.js";
|
|
11
|
+
import { sweep } from "../directory/registry.js";
|
|
12
|
+
import { createApp } from "./app.js";
|
|
13
|
+
/** Never print a password into a log that gets shipped somewhere. */
|
|
14
|
+
function redact(databaseUrl) {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(databaseUrl);
|
|
17
|
+
return `${url.protocol}//${url.hostname}:${url.port || '5432'}${url.pathname}`;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return 'the database';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function startServer(overrides = {}) {
|
|
24
|
+
const config = { ...loadConfig(), ...overrides };
|
|
25
|
+
const pool = getPool(config.databaseUrl);
|
|
26
|
+
// Said before the work starts, not after. Migrating against a cold database
|
|
27
|
+
// can take a few seconds, and a container that prints nothing at all in that
|
|
28
|
+
// window looks hung rather than busy.
|
|
29
|
+
console.log(`${config.boardName} starting: migrating ${redact(config.databaseUrl)}`);
|
|
30
|
+
const migrated = await migrate(pool);
|
|
31
|
+
if (migrated.applied.length > 0) {
|
|
32
|
+
console.log(`migrated: ${migrated.applied.join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
const app = createApp(pool, config);
|
|
35
|
+
const server = serve({ fetch: app.fetch, port: config.port, hostname: config.host });
|
|
36
|
+
console.log(`${config.boardName} on http://localhost:${config.port} (bound ${config.host})`);
|
|
37
|
+
console.log(` public url ${config.publicUrl}`);
|
|
38
|
+
if (config.ephemeralSecret) {
|
|
39
|
+
// Not a warning about style: every session and device code minted before a
|
|
40
|
+
// restart stops verifying, which looks like random sign-outs in production.
|
|
41
|
+
console.warn(' SECRET is unset, so one was generated. Sessions will not survive a restart.');
|
|
42
|
+
}
|
|
43
|
+
let announcer = null;
|
|
44
|
+
if (config.announce && config.directoryUrl !== null) {
|
|
45
|
+
announcer = startAnnouncing({
|
|
46
|
+
directoryUrl: config.directoryUrl,
|
|
47
|
+
publicUrl: config.publicUrl,
|
|
48
|
+
});
|
|
49
|
+
console.log(` announcing ${config.directoryUrl}`);
|
|
50
|
+
}
|
|
51
|
+
let sweeper = null;
|
|
52
|
+
if (config.isDirectory) {
|
|
53
|
+
console.log(' directory on');
|
|
54
|
+
const run = () => {
|
|
55
|
+
void sweep(pool)
|
|
56
|
+
.then((result) => {
|
|
57
|
+
if (result.failed > 0 || result.dropped > 0) {
|
|
58
|
+
console.log(`directory sweep: ${result.ok} ok, ${result.failed} failed, ${result.dropped} dropped`);
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
.catch((error) => console.error('directory sweep failed', error));
|
|
62
|
+
};
|
|
63
|
+
// The first sweep waits a minute so a cold start serves requests before it
|
|
64
|
+
// spends its connections re-reading forty other boards.
|
|
65
|
+
sweeper = setInterval(run, 10 * 60 * 1000);
|
|
66
|
+
sweeper.unref?.();
|
|
67
|
+
setTimeout(run, 60_000).unref?.();
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
config,
|
|
71
|
+
stop: async () => {
|
|
72
|
+
announcer?.stop();
|
|
73
|
+
if (sweeper !== null)
|
|
74
|
+
clearInterval(sweeper);
|
|
75
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=serve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serve.js","sourceRoot":"","sources":["../../src/server/serve.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The full-screen client.
|
|
3
|
+
*
|
|
4
|
+
* Both halves of the board in one window: find work, read what an agent has
|
|
5
|
+
* prepared for you and send it, and on the same account, publish a listing and
|
|
6
|
+
* read what came back. Which half you use is a tab, not a different program,
|
|
7
|
+
* because it is one person either way.
|
|
8
|
+
*
|
|
9
|
+
* Everything drawn lives in views.ts as pure functions; this file is the
|
|
10
|
+
* network, the keyboard and the state transitions and nothing else.
|
|
11
|
+
*/
|
|
12
|
+
import type { BoardClient } from '../client/client.ts';
|
|
13
|
+
export declare function startTui(client: BoardClient): Promise<void>;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The full-screen client.
|
|
3
|
+
*
|
|
4
|
+
* Both halves of the board in one window: find work, read what an agent has
|
|
5
|
+
* prepared for you and send it, and on the same account, publish a listing and
|
|
6
|
+
* read what came back. Which half you use is a tab, not a different program,
|
|
7
|
+
* because it is one person either way.
|
|
8
|
+
*
|
|
9
|
+
* Everything drawn lives in views.ts as pure functions; this file is the
|
|
10
|
+
* network, the keyboard and the state transitions and nothing else.
|
|
11
|
+
*/
|
|
12
|
+
import { createApp } from '@profullstack/hqtui';
|
|
13
|
+
import { ApiError } from "../client/client.js";
|
|
14
|
+
import { loadConfig } from "../client/config.js";
|
|
15
|
+
import { render } from "./views.js";
|
|
16
|
+
import { initialState, move, nextTab, selectedBoard, selectedDraft, selectedJob, } from "./state.js";
|
|
17
|
+
export async function startTui(client) {
|
|
18
|
+
let state = initialState(client.server);
|
|
19
|
+
const app = await createApp({
|
|
20
|
+
// The board is read far more than it changes, so a lower cap keeps a
|
|
21
|
+
// remote session responsive without redrawing a static list 30 times a
|
|
22
|
+
// second.
|
|
23
|
+
fps: 20,
|
|
24
|
+
quitKeys: [],
|
|
25
|
+
});
|
|
26
|
+
const invalidate = (next) => {
|
|
27
|
+
state = next;
|
|
28
|
+
app.invalidate();
|
|
29
|
+
};
|
|
30
|
+
const busy = async (label, work) => {
|
|
31
|
+
invalidate({ ...state, busy: label, error: null });
|
|
32
|
+
try {
|
|
33
|
+
const value = await work();
|
|
34
|
+
invalidate({ ...state, busy: null });
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
invalidate({
|
|
39
|
+
...state,
|
|
40
|
+
busy: null,
|
|
41
|
+
error: error instanceof ApiError ? error.message : String(error),
|
|
42
|
+
});
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const loadJobs = async () => {
|
|
47
|
+
const page = await busy('searching', () => client.search({ ...(state.query === '' ? {} : { q: state.query }), limit: 50 }));
|
|
48
|
+
if (page === null)
|
|
49
|
+
return;
|
|
50
|
+
invalidate({ ...state, jobs: page.items, jobsTotal: page.total, jobIndex: 0 });
|
|
51
|
+
};
|
|
52
|
+
const loadDrafts = async () => {
|
|
53
|
+
if (!state.signedIn)
|
|
54
|
+
return;
|
|
55
|
+
const result = await busy('loading drafts', () => client.request('GET', '/api/v1/applications/drafts'));
|
|
56
|
+
if (result === null)
|
|
57
|
+
return;
|
|
58
|
+
invalidate({ ...state, drafts: result.items, draftIndex: 0 });
|
|
59
|
+
};
|
|
60
|
+
const loadListings = async () => {
|
|
61
|
+
if (!state.canPost)
|
|
62
|
+
return;
|
|
63
|
+
const me = await busy('loading listings', () => client.me());
|
|
64
|
+
if (me === null)
|
|
65
|
+
return;
|
|
66
|
+
const slugs = new Set(me.orgs.map((org) => org.slug));
|
|
67
|
+
const pages = await Promise.all(me.orgs.map((org) => client.search({ org: org.slug, limit: 50 }).catch(() => null)));
|
|
68
|
+
const listings = pages
|
|
69
|
+
.filter((page) => page !== null)
|
|
70
|
+
.flatMap((page) => page.items)
|
|
71
|
+
.filter((job) => slugs.has(job.org.slug));
|
|
72
|
+
invalidate({ ...state, listings, listingIndex: 0 });
|
|
73
|
+
};
|
|
74
|
+
const loadBoards = () => {
|
|
75
|
+
const config = loadConfig();
|
|
76
|
+
invalidate({
|
|
77
|
+
...state,
|
|
78
|
+
boards: Object.values(config.boards).map((board) => ({
|
|
79
|
+
server: board.server,
|
|
80
|
+
name: board.name ?? board.server,
|
|
81
|
+
email: board.email ?? null,
|
|
82
|
+
current: board.server === config.current,
|
|
83
|
+
})),
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
const identify = async () => {
|
|
87
|
+
if (!client.hasToken())
|
|
88
|
+
return;
|
|
89
|
+
try {
|
|
90
|
+
const me = await client.me();
|
|
91
|
+
invalidate({ ...state, signedIn: true, canPost: me.orgs.length > 0 });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// An expired token is not an error worth a red bar on the first frame;
|
|
95
|
+
// the status bar already says "not signed in".
|
|
96
|
+
invalidate({ ...state, signedIn: false, canPost: false });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const handleKey = async (event) => {
|
|
100
|
+
const key = event.key;
|
|
101
|
+
if (state.editing) {
|
|
102
|
+
if (key === 'escape') {
|
|
103
|
+
invalidate({ ...state, editing: false });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (key === 'enter') {
|
|
107
|
+
invalidate({ ...state, editing: false });
|
|
108
|
+
await loadJobs();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key === 'backspace') {
|
|
112
|
+
invalidate({ ...state, query: state.query.slice(0, -1) });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Anything that is one printable character is typing. Named keys are
|
|
116
|
+
// longer than one character, which is the whole test.
|
|
117
|
+
if (key.length === 1 && event.ctrl !== true) {
|
|
118
|
+
invalidate({ ...state, query: state.query + key });
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (key === 'q' || (key === 'c' && event.ctrl === true)) {
|
|
123
|
+
app.stop();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (key === '/') {
|
|
127
|
+
invalidate({ ...state, editing: true, tab: 'find', detail: null, query: '' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (key === 'tab') {
|
|
131
|
+
const next = nextTab(state, 1);
|
|
132
|
+
invalidate(next);
|
|
133
|
+
await refresh(next.tab);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (key === 'escape' && state.detail !== null) {
|
|
137
|
+
invalidate({ ...state, detail: null });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (key === 'up' || key === 'k') {
|
|
141
|
+
invalidate(move(state, -1));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (key === 'down' || key === 'j') {
|
|
145
|
+
invalidate(move(state, 1));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (key === 'r') {
|
|
149
|
+
await refresh(state.tab);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (state.detail !== null) {
|
|
153
|
+
if (key === 'a' || key === 'd')
|
|
154
|
+
await apply(state.detail, key === 'd');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (key === 'enter') {
|
|
158
|
+
await activate();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (state.tab === 'listings' && (key === 'p' || key === 'c')) {
|
|
162
|
+
const job = selectedJob(state);
|
|
163
|
+
if (job === null)
|
|
164
|
+
return;
|
|
165
|
+
const action = key === 'p' ? 'publish' : 'close';
|
|
166
|
+
const result = await busy(action, () => client.request('POST', `/api/v1/jobs/${encodeURIComponent(job.slug)}/${action}`));
|
|
167
|
+
if (result !== null) {
|
|
168
|
+
invalidate({ ...state, message: `${job.title} is now ${result.job.status}.` });
|
|
169
|
+
await loadListings();
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const activate = async () => {
|
|
175
|
+
if (state.tab === 'find' || state.tab === 'listings') {
|
|
176
|
+
const job = selectedJob(state);
|
|
177
|
+
if (job !== null)
|
|
178
|
+
invalidate({ ...state, detail: job, message: null });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (state.tab === 'drafts') {
|
|
182
|
+
const draft = selectedDraft(state);
|
|
183
|
+
if (draft === null)
|
|
184
|
+
return;
|
|
185
|
+
const sent = await busy('sending', () => client.request('POST', `/api/v1/applications/${encodeURIComponent(draft.id)}/submit`));
|
|
186
|
+
if (sent !== null) {
|
|
187
|
+
invalidate({ ...state, message: `Sent your application for ${draft.jobTitle}.` });
|
|
188
|
+
await loadDrafts();
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (state.tab === 'boards') {
|
|
193
|
+
const board = selectedBoard(state);
|
|
194
|
+
if (board === null)
|
|
195
|
+
return;
|
|
196
|
+
// Switching boards inside the TUI would mean rebuilding every client and
|
|
197
|
+
// every list; saying so is more honest than half-doing it.
|
|
198
|
+
invalidate({
|
|
199
|
+
...state,
|
|
200
|
+
message: `Run: agenticjobs use ${board.server} (then restart the TUI)`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const apply = async (job, draft) => {
|
|
205
|
+
if (!state.signedIn) {
|
|
206
|
+
invalidate({ ...state, error: `Sign in first: agenticjobs login ${client.server}` });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const resumes = await busy('reading your resumes', () => client.resumes());
|
|
210
|
+
if (resumes === null)
|
|
211
|
+
return;
|
|
212
|
+
const first = resumes.items[0];
|
|
213
|
+
if (first === undefined) {
|
|
214
|
+
invalidate({
|
|
215
|
+
...state,
|
|
216
|
+
error: 'No resume saved. Run: agenticjobs resume import <file>',
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const me = await busy('applying', () => client.me());
|
|
221
|
+
if (me === null)
|
|
222
|
+
return;
|
|
223
|
+
const sent = await busy(draft ? 'preparing' : 'applying', () => client.apply(job.slug, {
|
|
224
|
+
name: me.user.name ?? me.user.email,
|
|
225
|
+
email: me.user.email,
|
|
226
|
+
cover: `Applying for ${job.title} at ${job.org.name}.`,
|
|
227
|
+
resumeSlug: first.slug,
|
|
228
|
+
...(draft ? { submit: false } : {}),
|
|
229
|
+
}));
|
|
230
|
+
if (sent === null)
|
|
231
|
+
return;
|
|
232
|
+
invalidate({
|
|
233
|
+
...state,
|
|
234
|
+
detail: null,
|
|
235
|
+
message: draft
|
|
236
|
+
? `Prepared for ${job.title}. Read it on the Drafts tab and send it there.`
|
|
237
|
+
: `Applied to ${job.title}.`,
|
|
238
|
+
});
|
|
239
|
+
if (draft)
|
|
240
|
+
await loadDrafts();
|
|
241
|
+
};
|
|
242
|
+
const refresh = async (tab) => {
|
|
243
|
+
if (tab === 'find')
|
|
244
|
+
await loadJobs();
|
|
245
|
+
else if (tab === 'drafts')
|
|
246
|
+
await loadDrafts();
|
|
247
|
+
else if (tab === 'listings')
|
|
248
|
+
await loadListings();
|
|
249
|
+
else
|
|
250
|
+
loadBoards();
|
|
251
|
+
};
|
|
252
|
+
// Registered after handleKey exists, so the listener never fires into a
|
|
253
|
+
// binding that is still in its temporal dead zone.
|
|
254
|
+
app.on('key', (event) => {
|
|
255
|
+
void handleKey(event);
|
|
256
|
+
});
|
|
257
|
+
app.render((args) => {
|
|
258
|
+
render(args.ui, args.theme, state);
|
|
259
|
+
});
|
|
260
|
+
loadBoards();
|
|
261
|
+
await identify();
|
|
262
|
+
await loadJobs();
|
|
263
|
+
await loadDrafts();
|
|
264
|
+
await app.start();
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=index.js.map
|