@cavuno/board 1.29.0 → 1.31.0-preview.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/dist/bin.mjs +364 -115
- package/dist/board--nLjpneU.d.ts +17 -0
- package/dist/board-BS8Ax7hz.d.mts +17 -0
- package/dist/filters.d.mts +1 -1
- package/dist/filters.d.ts +1 -1
- package/dist/format.d.mts +30 -2
- package/dist/format.d.ts +30 -2
- package/dist/format.js +76 -1
- package/dist/format.mjs +76 -1
- package/dist/index.d.mts +49 -6
- package/dist/index.d.ts +49 -6
- package/dist/index.js +22 -2
- package/dist/index.mjs +22 -2
- package/dist/{jobs-DK5mPBgq.d.mts → jobs-Dmz8uexp.d.mts} +3 -1
- package/dist/{jobs-DK5mPBgq.d.ts → jobs-Dmz8uexp.d.ts} +3 -1
- package/dist/{salaries-CXt6Vkrp.d.ts → salaries-CrtQBy81.d.ts} +2 -16
- package/dist/{salaries-CrJsaZe6.d.mts → salaries-QXFAyysR.d.mts} +2 -16
- package/dist/seo.d.mts +16 -3
- package/dist/seo.d.ts +16 -3
- package/dist/seo.js +21 -0
- package/dist/seo.mjs +21 -0
- package/dist/server.d.mts +3 -2
- package/dist/server.d.ts +3 -2
- package/dist/sitemap.d.mts +3 -2
- package/dist/sitemap.d.ts +3 -2
- package/package.json +1 -1
- package/skills/cavuno-board-smoke-test/SKILL.md +14 -0
- package/skills/manifest.json +1 -1
package/dist/bin.mjs
CHANGED
|
@@ -98,20 +98,8 @@ function summarize(results) {
|
|
|
98
98
|
return { exitCode: failed.length > 0 ? 1 : 0, passed, failed, skipped };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// src/doctor/
|
|
102
|
-
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
103
|
-
import { join } from "path";
|
|
101
|
+
// src/doctor/probe.ts
|
|
104
102
|
var FETCH_TIMEOUT_MS = 1e4;
|
|
105
|
-
var READ = {
|
|
106
|
-
home: record("read.home", 2),
|
|
107
|
-
jobs: record("read.jobs", 2),
|
|
108
|
-
jsonld: record("read.jsonld", 2),
|
|
109
|
-
sitemap: record("read.sitemap", 2),
|
|
110
|
-
robots: record("read.robots", 2)
|
|
111
|
-
};
|
|
112
|
-
var STATIC_API = record("static.api", 1);
|
|
113
|
-
var STATIC_BOARD = record("static.board", 1);
|
|
114
|
-
var STATIC_SKILLS = record("static.skills", 1);
|
|
115
103
|
async function probe(fetchImpl, url) {
|
|
116
104
|
try {
|
|
117
105
|
const response = await fetchImpl(url, {
|
|
@@ -130,6 +118,316 @@ async function probe(fetchImpl, url) {
|
|
|
130
118
|
};
|
|
131
119
|
}
|
|
132
120
|
}
|
|
121
|
+
|
|
122
|
+
// src/doctor/read.ts
|
|
123
|
+
var READ = {
|
|
124
|
+
home: record("read.home", 2),
|
|
125
|
+
jobs: record("read.jobs", 2),
|
|
126
|
+
jsonld: record("read.jsonld", 2),
|
|
127
|
+
sitemap: record("read.sitemap", 2),
|
|
128
|
+
robots: record("read.robots", 2)
|
|
129
|
+
};
|
|
130
|
+
function skipReadProbes(reason) {
|
|
131
|
+
return Object.values(READ).map((make) => make("skip", reason));
|
|
132
|
+
}
|
|
133
|
+
async function probeJobsAndJsonLd(fetchImpl, base) {
|
|
134
|
+
const jobs = await probe(fetchImpl, `${base}/jobs`);
|
|
135
|
+
const jobLink = jobs.ok ? extractJobDetailLink(jobs.body) : null;
|
|
136
|
+
const jobsResult = jobs.ok && jobLink ? READ.jobs("pass", `listing renders with job detail links (${jobLink})`) : READ.jobs(
|
|
137
|
+
"fail",
|
|
138
|
+
jobs.ok ? "listing renders but contains no /companies/{c}/jobs/{s} detail links" : `listing HTTP ${jobs.status}`
|
|
139
|
+
);
|
|
140
|
+
if (!jobLink) {
|
|
141
|
+
return [
|
|
142
|
+
jobsResult,
|
|
143
|
+
READ.jsonld(
|
|
144
|
+
"skip",
|
|
145
|
+
jobs.ok ? "not probed \u2014 the listing rendered no job detail link (see read.jobs failure)" : "not probed \u2014 the /jobs listing itself failed (see read.jobs failure)"
|
|
146
|
+
)
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
const detail = await probe(fetchImpl, `${base}${jobLink}`);
|
|
150
|
+
const posting = detail.ok ? extractJobPostingJsonLd(detail.body) : null;
|
|
151
|
+
return [
|
|
152
|
+
jobsResult,
|
|
153
|
+
posting ? READ.jsonld(
|
|
154
|
+
"pass",
|
|
155
|
+
`JobPosting JSON-LD present (${String(posting.title ?? "")})`
|
|
156
|
+
) : READ.jsonld(
|
|
157
|
+
"fail",
|
|
158
|
+
detail.ok ? "job detail page has no parseable JobPosting JSON-LD" : `job detail HTTP ${detail.status}`
|
|
159
|
+
)
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
function onFrontend(base, locUrl) {
|
|
163
|
+
try {
|
|
164
|
+
const parsed = new URL(locUrl);
|
|
165
|
+
return `${base}${parsed.pathname}${parsed.search}`;
|
|
166
|
+
} catch {
|
|
167
|
+
return locUrl;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function probeSitemap(fetchImpl, base) {
|
|
171
|
+
const sitemap = await probe(fetchImpl, `${base}/sitemap.xml`);
|
|
172
|
+
if (!sitemap.ok) {
|
|
173
|
+
return READ.sitemap("fail", `sitemap.xml HTTP ${sitemap.status}`);
|
|
174
|
+
}
|
|
175
|
+
const doc = parseSitemap(sitemap.body);
|
|
176
|
+
if (!doc || doc.urls.length === 0) {
|
|
177
|
+
return READ.sitemap(
|
|
178
|
+
"fail",
|
|
179
|
+
doc ? "sitemap.xml has no <loc> entries" : "sitemap.xml is not a sitemap"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
let urls = doc.urls;
|
|
183
|
+
if (doc.kind === "index") {
|
|
184
|
+
const child = await probe(fetchImpl, onFrontend(base, urls[0]));
|
|
185
|
+
const childDoc = child.ok ? parseSitemap(child.body) : null;
|
|
186
|
+
if (!childDoc || childDoc.urls.length === 0) {
|
|
187
|
+
return READ.sitemap(
|
|
188
|
+
"fail",
|
|
189
|
+
`child sitemap ${urls[0]} ${child.ok ? "has no <loc> entries" : `HTTP ${child.status}`}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
urls = childDoc.urls;
|
|
193
|
+
}
|
|
194
|
+
const sampleUrl = onFrontend(base, urls[0]);
|
|
195
|
+
const sample = await probe(fetchImpl, sampleUrl);
|
|
196
|
+
return sample.ok ? READ.sitemap("pass", `${urls.length} entries; sample page resolves`) : READ.sitemap("fail", `sitemap page ${sampleUrl} \u2192 HTTP ${sample.status}`);
|
|
197
|
+
}
|
|
198
|
+
async function runReadProbes(fetchImpl, frontendUrl) {
|
|
199
|
+
const base = frontendUrl.replace(/\/$/, "");
|
|
200
|
+
const [home, jobsAndJsonLd, sitemap, robots] = await Promise.all([
|
|
201
|
+
probe(fetchImpl, base),
|
|
202
|
+
probeJobsAndJsonLd(fetchImpl, base),
|
|
203
|
+
probeSitemap(fetchImpl, base),
|
|
204
|
+
probe(fetchImpl, `${base}/robots.txt`)
|
|
205
|
+
]);
|
|
206
|
+
return [
|
|
207
|
+
home.ok && /<(html|body|div|main)[\s>]/i.test(home.body) ? READ.home("pass", "home renders") : READ.home(
|
|
208
|
+
"fail",
|
|
209
|
+
home.ok ? "home returned 200 but no HTML document \u2014 captive portal or empty shell?" : `home HTTP ${home.status}`
|
|
210
|
+
),
|
|
211
|
+
...jobsAndJsonLd,
|
|
212
|
+
sitemap,
|
|
213
|
+
robots.ok && /user-agent\s*:/i.test(robots.body) ? READ.robots("pass", "present") : READ.robots(
|
|
214
|
+
"fail",
|
|
215
|
+
robots.ok ? "robots.txt returned 200 but has no User-agent directive" : `robots.txt HTTP ${robots.status}`
|
|
216
|
+
)
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/doctor/writes.ts
|
|
221
|
+
var WRITE = {
|
|
222
|
+
board: record("write.board", 3),
|
|
223
|
+
register: record("write.register", 3),
|
|
224
|
+
login: record("write.login", 3),
|
|
225
|
+
email: record("write.email", 3),
|
|
226
|
+
apply: record("write.apply", 3),
|
|
227
|
+
alert: record("write.alert", 3)
|
|
228
|
+
};
|
|
229
|
+
var DEPENDENT_CHECKS = [
|
|
230
|
+
WRITE.register,
|
|
231
|
+
WRITE.login,
|
|
232
|
+
WRITE.email,
|
|
233
|
+
WRITE.apply,
|
|
234
|
+
WRITE.alert
|
|
235
|
+
];
|
|
236
|
+
function skipWriteProbes(reason) {
|
|
237
|
+
return Object.values(WRITE).map((make) => make("skip", reason));
|
|
238
|
+
}
|
|
239
|
+
var FETCH_TIMEOUT_MS2 = 1e4;
|
|
240
|
+
var RESEND_API_BASE = "https://api.resend.com";
|
|
241
|
+
async function request(fetchImpl, url, init) {
|
|
242
|
+
try {
|
|
243
|
+
const response = await fetchImpl(url, {
|
|
244
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS2),
|
|
245
|
+
...init
|
|
246
|
+
});
|
|
247
|
+
let json = null;
|
|
248
|
+
try {
|
|
249
|
+
json = await response.json();
|
|
250
|
+
} catch {
|
|
251
|
+
json = null;
|
|
252
|
+
}
|
|
253
|
+
return { ok: response.ok, status: response.status, json };
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
status: 0,
|
|
258
|
+
json: { error: error instanceof Error ? error.message : String(error) }
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function post(fetchImpl, url, body, bearer) {
|
|
263
|
+
return request(fetchImpl, url, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
accept: "application/json",
|
|
268
|
+
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify(body)
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
274
|
+
async function observeVerificationEmail(fetchImpl, apiKey, recipient, pollMs, timeoutMs) {
|
|
275
|
+
const deadline = Date.now() + timeoutMs;
|
|
276
|
+
const subjectMarker = `[To: ${recipient}]`;
|
|
277
|
+
do {
|
|
278
|
+
const list = await request(
|
|
279
|
+
fetchImpl,
|
|
280
|
+
`${RESEND_API_BASE}/emails?limit=20`,
|
|
281
|
+
{
|
|
282
|
+
headers: { authorization: `Bearer ${apiKey}` }
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
if (!list.ok) {
|
|
286
|
+
return WRITE.email(
|
|
287
|
+
"fail",
|
|
288
|
+
`Resend list returned HTTP ${list.status} \u2014 wrong RESEND_API_KEY?`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const items = list.json?.data ?? [];
|
|
292
|
+
const match = items.find(
|
|
293
|
+
(item) => item.subject?.includes(subjectMarker) || item.to?.includes(recipient)
|
|
294
|
+
);
|
|
295
|
+
if (match) {
|
|
296
|
+
return WRITE.email(
|
|
297
|
+
"pass",
|
|
298
|
+
`verification send observed in Resend for ${recipient}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
await sleep(pollMs);
|
|
302
|
+
} while (Date.now() < deadline);
|
|
303
|
+
return WRITE.email(
|
|
304
|
+
"fail",
|
|
305
|
+
`no verification send for ${recipient} appeared in Resend within ${timeoutMs}ms`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
async function runWriteProbes(options) {
|
|
309
|
+
const { fetchImpl } = options;
|
|
310
|
+
const origin = new URL(options.env.apiUrl).origin;
|
|
311
|
+
const base = `${origin}/v1/boards/${encodeURIComponent(options.env.boardKey)}`;
|
|
312
|
+
const nonce = options.nonce ?? `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
313
|
+
const email = `doctor+${nonce}@cavuno.com`;
|
|
314
|
+
const password = `doctor-probe-${nonce}-A1!`;
|
|
315
|
+
let context = options.boardContext ?? null;
|
|
316
|
+
if (context === null) {
|
|
317
|
+
const r = await request(fetchImpl, base);
|
|
318
|
+
if (!r.ok) {
|
|
319
|
+
return [
|
|
320
|
+
WRITE.board("fail", `board context did not resolve (HTTP ${r.status})`),
|
|
321
|
+
...DEPENDENT_CHECKS.map(
|
|
322
|
+
(make) => make("skip", "not run \u2014 the write gate failed (see write.board)")
|
|
323
|
+
)
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
context = r.json ?? {};
|
|
327
|
+
}
|
|
328
|
+
if (context.sandbox !== true) {
|
|
329
|
+
return [
|
|
330
|
+
WRITE.board(
|
|
331
|
+
"fail",
|
|
332
|
+
"refusing write probes: this board is not the platform sandbox (`sandbox` is not true) \u2014 point PUBLIC_CAVUNO_BOARD at the sandbox pk_"
|
|
333
|
+
),
|
|
334
|
+
...DEPENDENT_CHECKS.map(
|
|
335
|
+
(make) => make("skip", "not run \u2014 the write gate failed (see write.board)")
|
|
336
|
+
)
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
const results = [
|
|
340
|
+
WRITE.board("pass", "board is the platform sandbox")
|
|
341
|
+
];
|
|
342
|
+
const registered = await post(fetchImpl, `${base}/auth/register`, {
|
|
343
|
+
role: "candidate",
|
|
344
|
+
method: "emailpass",
|
|
345
|
+
email,
|
|
346
|
+
password,
|
|
347
|
+
displayName: "Doctor Probe"
|
|
348
|
+
});
|
|
349
|
+
const sessionOk = (r) => r.ok && typeof r.json?.accessToken === "string";
|
|
350
|
+
results.push(
|
|
351
|
+
sessionOk(registered) ? WRITE.register("pass", `candidate registered (${email})`) : WRITE.register(
|
|
352
|
+
"fail",
|
|
353
|
+
`register returned HTTP ${registered.status}` + (registered.status === 429 ? " \u2014 rate-limited: register allows 5 signups / 15 min per IP; wait before rerunning" : "")
|
|
354
|
+
)
|
|
355
|
+
);
|
|
356
|
+
let accessToken = null;
|
|
357
|
+
if (sessionOk(registered)) {
|
|
358
|
+
const login = await post(fetchImpl, `${base}/auth/login`, {
|
|
359
|
+
email,
|
|
360
|
+
password
|
|
361
|
+
});
|
|
362
|
+
if (sessionOk(login)) {
|
|
363
|
+
accessToken = login.json.accessToken;
|
|
364
|
+
results.push(WRITE.login("pass", "emailpass login returns a session"));
|
|
365
|
+
} else {
|
|
366
|
+
results.push(WRITE.login("fail", `login returned HTTP ${login.status}`));
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
results.push(WRITE.login("skip", "not run \u2014 register failed"));
|
|
370
|
+
}
|
|
371
|
+
if (!sessionOk(registered)) {
|
|
372
|
+
results.push(WRITE.email("skip", "not run \u2014 register failed"));
|
|
373
|
+
} else if (!options.resendApiKey) {
|
|
374
|
+
results.push(
|
|
375
|
+
WRITE.email(
|
|
376
|
+
"skip",
|
|
377
|
+
"RESEND_API_KEY not set \u2014 the verification send was NOT verified (platform-operator check)"
|
|
378
|
+
)
|
|
379
|
+
);
|
|
380
|
+
} else {
|
|
381
|
+
results.push(
|
|
382
|
+
await observeVerificationEmail(
|
|
383
|
+
fetchImpl,
|
|
384
|
+
options.resendApiKey,
|
|
385
|
+
email,
|
|
386
|
+
options.emailPollMs ?? 2e3,
|
|
387
|
+
options.emailTimeoutMs ?? 3e4
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (!accessToken) {
|
|
392
|
+
results.push(WRITE.apply("skip", "not run \u2014 no session (see above)"));
|
|
393
|
+
} else {
|
|
394
|
+
const listing = await request(fetchImpl, `${base}/jobs?limit=1`);
|
|
395
|
+
const card = listing.json?.data?.[0];
|
|
396
|
+
if (!listing.ok || !card?.slug) {
|
|
397
|
+
results.push(
|
|
398
|
+
WRITE.apply(
|
|
399
|
+
"fail",
|
|
400
|
+
listing.ok ? "the board has no jobs to apply to \u2014 is the sandbox seeded?" : `jobs listing returned HTTP ${listing.status}`
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
} else {
|
|
404
|
+
const applied = await post(
|
|
405
|
+
fetchImpl,
|
|
406
|
+
`${base}/jobs/${encodeURIComponent(card.slug)}/apply`,
|
|
407
|
+
{},
|
|
408
|
+
accessToken
|
|
409
|
+
);
|
|
410
|
+
results.push(
|
|
411
|
+
applied.ok && typeof applied.json?.id === "string" ? WRITE.apply("pass", `applied to ${card.slug}`) : WRITE.apply("fail", `apply returned HTTP ${applied.status}`)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const alert = await post(fetchImpl, `${base}/job-alerts`, {
|
|
416
|
+
email,
|
|
417
|
+
consent: true
|
|
418
|
+
});
|
|
419
|
+
results.push(
|
|
420
|
+
alert.ok ? WRITE.alert("pass", "job-alert subscription accepted") : WRITE.alert("fail", `job-alerts returned HTTP ${alert.status}`)
|
|
421
|
+
);
|
|
422
|
+
return results;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/doctor/run.ts
|
|
426
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
427
|
+
import { join } from "path";
|
|
428
|
+
var STATIC_API = record("static.api", 1);
|
|
429
|
+
var STATIC_BOARD = record("static.board", 1);
|
|
430
|
+
var STATIC_SKILLS = record("static.skills", 1);
|
|
133
431
|
async function checkApiReachable(fetchImpl, apiUrl) {
|
|
134
432
|
let origin;
|
|
135
433
|
try {
|
|
@@ -163,21 +461,31 @@ async function checkBoardResolves(fetchImpl, env) {
|
|
|
163
461
|
`${origin}/v1/boards/${encodeURIComponent(env.boardKey)}`
|
|
164
462
|
);
|
|
165
463
|
if (!result.ok) {
|
|
166
|
-
return
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
464
|
+
return {
|
|
465
|
+
result: STATIC_BOARD(
|
|
466
|
+
"fail",
|
|
467
|
+
`board did not resolve for this key (HTTP ${result.status}) \u2014 revoked or wrong key?`
|
|
468
|
+
),
|
|
469
|
+
context: null
|
|
470
|
+
};
|
|
170
471
|
}
|
|
472
|
+
let parsed = null;
|
|
171
473
|
try {
|
|
172
|
-
|
|
474
|
+
parsed = JSON.parse(result.body);
|
|
173
475
|
if (typeof parsed !== "object" || parsed === null) throw new Error("shape");
|
|
174
476
|
} catch {
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
477
|
+
return {
|
|
478
|
+
result: STATIC_BOARD(
|
|
479
|
+
"fail",
|
|
480
|
+
"board endpoint returned 200 but not JSON \u2014 proxy or captive portal in the way?"
|
|
481
|
+
),
|
|
482
|
+
context: null
|
|
483
|
+
};
|
|
179
484
|
}
|
|
180
|
-
return
|
|
485
|
+
return {
|
|
486
|
+
result: STATIC_BOARD("pass", "publishable key resolves the board"),
|
|
487
|
+
context: parsed
|
|
488
|
+
};
|
|
181
489
|
}
|
|
182
490
|
var SKILL_ROOTS = [".claude/skills", ".agents/skills"];
|
|
183
491
|
function checkSkillsFreshness(projectRoot) {
|
|
@@ -218,71 +526,6 @@ function checkSkillsFreshness(projectRoot) {
|
|
|
218
526
|
`stale skills (re-run \`npx @cavuno/board setup\`): ${[...stale].join(", ")}`
|
|
219
527
|
);
|
|
220
528
|
}
|
|
221
|
-
async function probeJobsAndJsonLd(fetchImpl, base) {
|
|
222
|
-
const jobs = await probe(fetchImpl, `${base}/jobs`);
|
|
223
|
-
const jobLink = jobs.ok ? extractJobDetailLink(jobs.body) : null;
|
|
224
|
-
const jobsResult = jobs.ok && jobLink ? READ.jobs("pass", `listing renders with job detail links (${jobLink})`) : READ.jobs(
|
|
225
|
-
"fail",
|
|
226
|
-
jobs.ok ? "listing renders but contains no /companies/{c}/jobs/{s} detail links" : `listing HTTP ${jobs.status}`
|
|
227
|
-
);
|
|
228
|
-
if (!jobLink) {
|
|
229
|
-
return [
|
|
230
|
-
jobsResult,
|
|
231
|
-
READ.jsonld(
|
|
232
|
-
"skip",
|
|
233
|
-
jobs.ok ? "not probed \u2014 the listing rendered no job detail link (see read.jobs failure)" : "not probed \u2014 the /jobs listing itself failed (see read.jobs failure)"
|
|
234
|
-
)
|
|
235
|
-
];
|
|
236
|
-
}
|
|
237
|
-
const detail = await probe(fetchImpl, `${base}${jobLink}`);
|
|
238
|
-
const posting = detail.ok ? extractJobPostingJsonLd(detail.body) : null;
|
|
239
|
-
return [
|
|
240
|
-
jobsResult,
|
|
241
|
-
posting ? READ.jsonld(
|
|
242
|
-
"pass",
|
|
243
|
-
`JobPosting JSON-LD present (${String(posting.title ?? "")})`
|
|
244
|
-
) : READ.jsonld(
|
|
245
|
-
"fail",
|
|
246
|
-
detail.ok ? "job detail page has no parseable JobPosting JSON-LD" : `job detail HTTP ${detail.status}`
|
|
247
|
-
)
|
|
248
|
-
];
|
|
249
|
-
}
|
|
250
|
-
function onFrontend(base, locUrl) {
|
|
251
|
-
try {
|
|
252
|
-
const parsed = new URL(locUrl);
|
|
253
|
-
return `${base}${parsed.pathname}${parsed.search}`;
|
|
254
|
-
} catch {
|
|
255
|
-
return locUrl;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
async function probeSitemap(fetchImpl, base) {
|
|
259
|
-
const sitemap = await probe(fetchImpl, `${base}/sitemap.xml`);
|
|
260
|
-
if (!sitemap.ok) {
|
|
261
|
-
return READ.sitemap("fail", `sitemap.xml HTTP ${sitemap.status}`);
|
|
262
|
-
}
|
|
263
|
-
const doc = parseSitemap(sitemap.body);
|
|
264
|
-
if (!doc || doc.urls.length === 0) {
|
|
265
|
-
return READ.sitemap(
|
|
266
|
-
"fail",
|
|
267
|
-
doc ? "sitemap.xml has no <loc> entries" : "sitemap.xml is not a sitemap"
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
let urls = doc.urls;
|
|
271
|
-
if (doc.kind === "index") {
|
|
272
|
-
const child = await probe(fetchImpl, onFrontend(base, urls[0]));
|
|
273
|
-
const childDoc = child.ok ? parseSitemap(child.body) : null;
|
|
274
|
-
if (!childDoc || childDoc.urls.length === 0) {
|
|
275
|
-
return READ.sitemap(
|
|
276
|
-
"fail",
|
|
277
|
-
`child sitemap ${urls[0]} ${child.ok ? "has no <loc> entries" : `HTTP ${child.status}`}`
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
urls = childDoc.urls;
|
|
281
|
-
}
|
|
282
|
-
const sampleUrl = onFrontend(base, urls[0]);
|
|
283
|
-
const sample = await probe(fetchImpl, sampleUrl);
|
|
284
|
-
return sample.ok ? READ.sitemap("pass", `${urls.length} entries; sample page resolves`) : READ.sitemap("fail", `sitemap page ${sampleUrl} \u2192 HTTP ${sample.status}`);
|
|
285
|
-
}
|
|
286
529
|
async function runDoctor(options) {
|
|
287
530
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
288
531
|
const results = [];
|
|
@@ -297,35 +540,37 @@ async function runDoctor(options) {
|
|
|
297
540
|
} else {
|
|
298
541
|
results.push(STATIC_API("skip", "PUBLIC_CAVUNO_API_URL not set"));
|
|
299
542
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
return { results, summary: summarize(results) };
|
|
543
|
+
let boardContext = null;
|
|
544
|
+
if (envOk) {
|
|
545
|
+
const resolved = await checkBoardResolves(fetchImpl, options.env);
|
|
546
|
+
boardContext = resolved.context;
|
|
547
|
+
results.push(resolved.result);
|
|
548
|
+
} else {
|
|
549
|
+
results.push(STATIC_BOARD("skip", "env checks failed \u2014 fix them first"));
|
|
309
550
|
}
|
|
310
|
-
|
|
311
|
-
const [home, jobsAndJsonLd, sitemap, robots] = await Promise.all([
|
|
312
|
-
probe(fetchImpl, base),
|
|
313
|
-
probeJobsAndJsonLd(fetchImpl, base),
|
|
314
|
-
probeSitemap(fetchImpl, base),
|
|
315
|
-
probe(fetchImpl, `${base}/robots.txt`)
|
|
316
|
-
]);
|
|
551
|
+
results.push(checkSkillsFreshness(options.projectRoot ?? process.cwd()));
|
|
317
552
|
results.push(
|
|
318
|
-
|
|
319
|
-
"fail",
|
|
320
|
-
home.ok ? "home returned 200 but no HTML document \u2014 captive portal or empty shell?" : `home HTTP ${home.status}`
|
|
321
|
-
),
|
|
322
|
-
...jobsAndJsonLd,
|
|
323
|
-
sitemap,
|
|
324
|
-
robots.ok && /user-agent\s*:/i.test(robots.body) ? READ.robots("pass", "present") : READ.robots(
|
|
325
|
-
"fail",
|
|
326
|
-
robots.ok ? "robots.txt returned 200 but has no User-agent directive" : `robots.txt HTTP ${robots.status}`
|
|
327
|
-
)
|
|
553
|
+
...options.frontendUrl ? await runReadProbes(fetchImpl, options.frontendUrl) : skipReadProbes("no --frontend <url> given")
|
|
328
554
|
);
|
|
555
|
+
if (!options.sandbox) {
|
|
556
|
+
results.push(
|
|
557
|
+
...skipWriteProbes(
|
|
558
|
+
"write probes not enabled \u2014 run with --sandbox against the platform sandbox"
|
|
559
|
+
)
|
|
560
|
+
);
|
|
561
|
+
} else if (!envOk) {
|
|
562
|
+
results.push(...skipWriteProbes("env checks failed \u2014 fix them first"));
|
|
563
|
+
} else {
|
|
564
|
+
results.push(
|
|
565
|
+
...await runWriteProbes({
|
|
566
|
+
env: options.env,
|
|
567
|
+
fetchImpl,
|
|
568
|
+
resendApiKey: options.resendApiKey,
|
|
569
|
+
nonce: options.writeProbeNonce,
|
|
570
|
+
boardContext
|
|
571
|
+
})
|
|
572
|
+
);
|
|
573
|
+
}
|
|
329
574
|
return { results, summary: summarize(results) };
|
|
330
575
|
}
|
|
331
576
|
|
|
@@ -375,7 +620,11 @@ async function doctor(argv) {
|
|
|
375
620
|
apiUrl: process.env.PUBLIC_CAVUNO_API_URL,
|
|
376
621
|
boardKey: process.env.PUBLIC_CAVUNO_BOARD
|
|
377
622
|
},
|
|
378
|
-
frontendUrl
|
|
623
|
+
frontendUrl,
|
|
624
|
+
// Tier-3 write probes: opt-in via --sandbox, and refused server-side
|
|
625
|
+
// unless the resolved board is platform-marked test (writes.ts gate).
|
|
626
|
+
sandbox: argv.includes("--sandbox"),
|
|
627
|
+
resendApiKey: process.env.RESEND_API_KEY
|
|
379
628
|
});
|
|
380
629
|
console.log("\n@cavuno/board doctor");
|
|
381
630
|
for (const result of results) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { b as Schemas } from './jobs-Dmz8uexp.js';
|
|
2
|
+
|
|
3
|
+
type PublicBoard = Schemas['PublicBoardContext'];
|
|
4
|
+
type PublicBoardFeatures = PublicBoard['features'];
|
|
5
|
+
type PublicBoardAnalytics = PublicBoard['analytics'];
|
|
6
|
+
type PublicBoardTheme = NonNullable<PublicBoard['theme']>;
|
|
7
|
+
/**
|
|
8
|
+
* An operator-defined custom job-field definition (CAV-294). Board-wide;
|
|
9
|
+
* use it to render and localize a job's opaque `customFieldValues` (resolve
|
|
10
|
+
* option `key`s → labels, honour field `type` and display order). Shared with
|
|
11
|
+
* the Operator API's custom-field surface (one canonical schema).
|
|
12
|
+
*/
|
|
13
|
+
type CustomFieldDefinition = Schemas['CustomFieldDefinition'];
|
|
14
|
+
type CustomFieldType = CustomFieldDefinition['type'];
|
|
15
|
+
type CustomFieldOption = Schemas['CustomFieldOption'];
|
|
16
|
+
|
|
17
|
+
export type { CustomFieldDefinition as C, PublicBoard as P, CustomFieldOption as a, CustomFieldType as b, PublicBoardAnalytics as c, PublicBoardFeatures as d, PublicBoardTheme as e };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { b as Schemas } from './jobs-Dmz8uexp.mjs';
|
|
2
|
+
|
|
3
|
+
type PublicBoard = Schemas['PublicBoardContext'];
|
|
4
|
+
type PublicBoardFeatures = PublicBoard['features'];
|
|
5
|
+
type PublicBoardAnalytics = PublicBoard['analytics'];
|
|
6
|
+
type PublicBoardTheme = NonNullable<PublicBoard['theme']>;
|
|
7
|
+
/**
|
|
8
|
+
* An operator-defined custom job-field definition (CAV-294). Board-wide;
|
|
9
|
+
* use it to render and localize a job's opaque `customFieldValues` (resolve
|
|
10
|
+
* option `key`s → labels, honour field `type` and display order). Shared with
|
|
11
|
+
* the Operator API's custom-field surface (one canonical schema).
|
|
12
|
+
*/
|
|
13
|
+
type CustomFieldDefinition = Schemas['CustomFieldDefinition'];
|
|
14
|
+
type CustomFieldType = CustomFieldDefinition['type'];
|
|
15
|
+
type CustomFieldOption = Schemas['CustomFieldOption'];
|
|
16
|
+
|
|
17
|
+
export type { CustomFieldDefinition as C, PublicBoard as P, CustomFieldOption as a, CustomFieldType as b, PublicBoardAnalytics as c, PublicBoardFeatures as d, PublicBoardTheme as e };
|
package/dist/filters.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-
|
|
1
|
+
import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-Dmz8uexp.mjs';
|
|
2
2
|
|
|
3
3
|
declare const REMOTE_OPTIONS: readonly RemoteOption[];
|
|
4
4
|
/**
|
package/dist/filters.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-
|
|
1
|
+
import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-Dmz8uexp.js';
|
|
2
2
|
|
|
3
3
|
declare const REMOTE_OPTIONS: readonly RemoteOption[];
|
|
4
4
|
/**
|
package/dist/format.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { P as PublicJob, a as PublicJobCard } from './jobs-
|
|
1
|
+
import { P as PublicJob, a as PublicJobCard, C as CustomFieldValues } from './jobs-Dmz8uexp.mjs';
|
|
2
|
+
import { C as CustomFieldDefinition } from './board-BS8Ax7hz.mjs';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Date display helpers in the board language (required leading parameter,
|
|
@@ -237,4 +238,31 @@ interface SalaryTimeframeOverrides {
|
|
|
237
238
|
*/
|
|
238
239
|
declare function formatSalaryRange(language: string | undefined, min: number | null, max: number | null, timeframe: SalaryTimeframeInput, currency?: string | null, timeframeOverrides?: SalaryTimeframeOverrides): string | null;
|
|
239
240
|
|
|
240
|
-
|
|
241
|
+
declare function companyIntro(summary: string | null, description: string | null): string | null;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve a job's opaque `customFieldValues` into ordered, display-ready
|
|
245
|
+
* entries for the "Additional details" section — the client-side twin of the
|
|
246
|
+
* hosted `resolveCustomFieldDisplay` (apps/web/lib/jobs/job-form-config/
|
|
247
|
+
* custom-field-display.ts). Pure. Iterates the *definitions* (from board
|
|
248
|
+
* context) in config order, so display order is operator-controlled, orphan
|
|
249
|
+
* values (deleted definitions) are never inspected, and empty values drop per
|
|
250
|
+
* field. Select values resolve their stored option KEY to the current label
|
|
251
|
+
* (rename-safe); an option with no current match is dropped. Booleans stay raw
|
|
252
|
+
* (a discriminated `kind`) — the render layer authors the "Yes"/"No" chrome
|
|
253
|
+
* (English; localized copy is the use-intl follow-up).
|
|
254
|
+
*/
|
|
255
|
+
type CustomFieldDisplayEntry = {
|
|
256
|
+
key: string;
|
|
257
|
+
label: string;
|
|
258
|
+
kind: 'text';
|
|
259
|
+
value: string;
|
|
260
|
+
} | {
|
|
261
|
+
key: string;
|
|
262
|
+
label: string;
|
|
263
|
+
kind: 'boolean';
|
|
264
|
+
value: boolean;
|
|
265
|
+
};
|
|
266
|
+
declare function resolveCustomFieldDisplay(definitions: CustomFieldDefinition[] | undefined, values: CustomFieldValues | undefined): CustomFieldDisplayEntry[];
|
|
267
|
+
|
|
268
|
+
export { type CustomFieldDisplayEntry, type SalaryFrames, type SalaryLexicon, type SalaryTimeframeInput, type SalaryTimeframeOverrides, type SalaryTimeframeValue, type SeniorityKey, cardLocationLabel, companyIntro, fieldLabel, formatDate, formatMonthYear, formatPublishedRelativeDate, formatSalaryRange, fullJobToCard, getSalaryLexicon, locationLabel, resolveCustomFieldDisplay };
|
package/dist/format.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { P as PublicJob, a as PublicJobCard } from './jobs-
|
|
1
|
+
import { P as PublicJob, a as PublicJobCard, C as CustomFieldValues } from './jobs-Dmz8uexp.js';
|
|
2
|
+
import { C as CustomFieldDefinition } from './board--nLjpneU.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Date display helpers in the board language (required leading parameter,
|
|
@@ -237,4 +238,31 @@ interface SalaryTimeframeOverrides {
|
|
|
237
238
|
*/
|
|
238
239
|
declare function formatSalaryRange(language: string | undefined, min: number | null, max: number | null, timeframe: SalaryTimeframeInput, currency?: string | null, timeframeOverrides?: SalaryTimeframeOverrides): string | null;
|
|
239
240
|
|
|
240
|
-
|
|
241
|
+
declare function companyIntro(summary: string | null, description: string | null): string | null;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve a job's opaque `customFieldValues` into ordered, display-ready
|
|
245
|
+
* entries for the "Additional details" section — the client-side twin of the
|
|
246
|
+
* hosted `resolveCustomFieldDisplay` (apps/web/lib/jobs/job-form-config/
|
|
247
|
+
* custom-field-display.ts). Pure. Iterates the *definitions* (from board
|
|
248
|
+
* context) in config order, so display order is operator-controlled, orphan
|
|
249
|
+
* values (deleted definitions) are never inspected, and empty values drop per
|
|
250
|
+
* field. Select values resolve their stored option KEY to the current label
|
|
251
|
+
* (rename-safe); an option with no current match is dropped. Booleans stay raw
|
|
252
|
+
* (a discriminated `kind`) — the render layer authors the "Yes"/"No" chrome
|
|
253
|
+
* (English; localized copy is the use-intl follow-up).
|
|
254
|
+
*/
|
|
255
|
+
type CustomFieldDisplayEntry = {
|
|
256
|
+
key: string;
|
|
257
|
+
label: string;
|
|
258
|
+
kind: 'text';
|
|
259
|
+
value: string;
|
|
260
|
+
} | {
|
|
261
|
+
key: string;
|
|
262
|
+
label: string;
|
|
263
|
+
kind: 'boolean';
|
|
264
|
+
value: boolean;
|
|
265
|
+
};
|
|
266
|
+
declare function resolveCustomFieldDisplay(definitions: CustomFieldDefinition[] | undefined, values: CustomFieldValues | undefined): CustomFieldDisplayEntry[];
|
|
267
|
+
|
|
268
|
+
export { type CustomFieldDisplayEntry, type SalaryFrames, type SalaryLexicon, type SalaryTimeframeInput, type SalaryTimeframeOverrides, type SalaryTimeframeValue, type SeniorityKey, cardLocationLabel, companyIntro, fieldLabel, formatDate, formatMonthYear, formatPublishedRelativeDate, formatSalaryRange, fullJobToCard, getSalaryLexicon, locationLabel, resolveCustomFieldDisplay };
|