@cavuno/board 1.28.1 → 1.30.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 CHANGED
@@ -1,11 +1,4 @@
1
1
  #!/usr/bin/env node
2
- var __getOwnPropNames = Object.getOwnPropertyNames;
3
- var __esm = (fn, res) => function __init() {
4
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
- };
6
- var __commonJS = (cb, mod) => function __require() {
7
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
8
- };
9
2
 
10
3
  // src/skills.ts
11
4
  import { readFileSync } from "fs";
@@ -14,26 +7,580 @@ import { fileURLToPath } from "url";
14
7
  function packageRoot() {
15
8
  return resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
9
  }
17
- function resolveFromPackageRoot(relativePath) {
18
- return resolve(packageRoot(), relativePath);
10
+ function resolveFromPackageRoot(relativePath, baseDir) {
11
+ return resolve(baseDir ?? packageRoot(), relativePath);
19
12
  }
20
- function loadSkillManifest() {
21
- const manifestPath = resolveFromPackageRoot("skills/manifest.json");
13
+ function loadSkillManifest(baseDir) {
14
+ const manifestPath = resolveFromPackageRoot("skills/manifest.json", baseDir);
22
15
  return JSON.parse(readFileSync(manifestPath, "utf8"));
23
16
  }
24
- var init_skills = __esm({
25
- "src/skills.ts"() {
26
- "use strict";
17
+ function loadSkillCorpus(baseDir) {
18
+ const manifest = loadSkillManifest(baseDir);
19
+ return {
20
+ version: manifest.version,
21
+ skills: manifest.skills.map((skill) => ({
22
+ ...skill,
23
+ content: readFileSync(
24
+ resolveFromPackageRoot(skill.path, baseDir),
25
+ "utf8"
26
+ )
27
+ }))
28
+ };
29
+ }
30
+
31
+ // src/doctor/checks.ts
32
+ function record(id, tier) {
33
+ return (status, detail) => ({
34
+ id,
35
+ tier,
36
+ status,
37
+ detail
38
+ });
39
+ }
40
+ var ENV_API_URL = record("env.api-url", 1);
41
+ var ENV_BOARD_KEY = record("env.board-key", 1);
42
+ var PK_RE = /^pk_[0-9a-f]{32}$/;
43
+ function checkEnv(env) {
44
+ const results = [];
45
+ if (!env.apiUrl) {
46
+ results.push(ENV_API_URL("fail", "PUBLIC_CAVUNO_API_URL is not set"));
47
+ } else {
48
+ let origin = null;
49
+ try {
50
+ origin = new URL(env.apiUrl).origin;
51
+ } catch {
52
+ origin = null;
53
+ }
54
+ results.push(
55
+ origin ? ENV_API_URL("pass", origin) : ENV_API_URL(
56
+ "fail",
57
+ `PUBLIC_CAVUNO_API_URL is not a valid URL: ${env.apiUrl}`
58
+ )
59
+ );
60
+ }
61
+ if (!env.boardKey) {
62
+ results.push(ENV_BOARD_KEY("fail", "PUBLIC_CAVUNO_BOARD is not set"));
63
+ } else {
64
+ results.push(
65
+ PK_RE.test(env.boardKey) ? ENV_BOARD_KEY("pass", "pk_ key") : ENV_BOARD_KEY(
66
+ "fail",
67
+ "PUBLIC_CAVUNO_BOARD must be a publishable key (pk_ + 32 hex chars)"
68
+ )
69
+ );
70
+ }
71
+ return results;
72
+ }
73
+ var JOB_DETAIL_LINK_RE = /href="(\/companies\/[a-z0-9-]+\/jobs\/[a-z0-9-]+)"/i;
74
+ function extractJobDetailLink(html) {
75
+ return html.match(JOB_DETAIL_LINK_RE)?.[1] ?? null;
76
+ }
77
+ var JSON_LD_RE = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
78
+ function extractJobPostingJsonLd(html) {
79
+ for (const match of html.matchAll(JSON_LD_RE)) {
80
+ try {
81
+ const parsed = JSON.parse(match[1].trim());
82
+ if (parsed["@type"] === "JobPosting") return parsed;
83
+ } catch {
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ var LOC_RE = /<loc>([^<]+)<\/loc>/gi;
89
+ function parseSitemap(xml) {
90
+ const kind = /<urlset[\s>]/i.test(xml) ? "urlset" : /<sitemapindex[\s>]/i.test(xml) ? "index" : null;
91
+ if (!kind) return null;
92
+ return { kind, urls: [...xml.matchAll(LOC_RE)].map((m) => m[1].trim()) };
93
+ }
94
+ function summarize(results) {
95
+ const passed = results.filter((r) => r.status === "pass").map((r) => r.id);
96
+ const failed = results.filter((r) => r.status === "fail").map((r) => r.id);
97
+ const skipped = results.filter((r) => r.status === "skip").map((r) => r.id);
98
+ return { exitCode: failed.length > 0 ? 1 : 0, passed, failed, skipped };
99
+ }
100
+
101
+ // src/doctor/probe.ts
102
+ var FETCH_TIMEOUT_MS = 1e4;
103
+ async function probe(fetchImpl, url) {
104
+ try {
105
+ const response = await fetchImpl(url, {
106
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
107
+ });
108
+ return {
109
+ ok: response.ok,
110
+ status: response.status,
111
+ body: await response.text()
112
+ };
113
+ } catch (error) {
114
+ return {
115
+ ok: false,
116
+ status: 0,
117
+ body: error instanceof Error ? error.message : String(error)
118
+ };
119
+ }
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);
431
+ async function checkApiReachable(fetchImpl, apiUrl) {
432
+ let origin;
433
+ try {
434
+ origin = new URL(apiUrl).origin;
435
+ } catch {
436
+ return null;
437
+ }
438
+ const spec = await probe(fetchImpl, `${origin}/api/v1/openapi.json`);
439
+ const result = spec.ok ? spec : await probe(fetchImpl, `${origin}/v1/openapi.json`);
440
+ if (!result.ok) {
441
+ return STATIC_API(
442
+ "fail",
443
+ `OpenAPI spec unreachable (HTTP ${result.status})`
444
+ );
27
445
  }
28
- });
446
+ try {
447
+ const parsed = JSON.parse(result.body);
448
+ if (typeof parsed.openapi !== "string") throw new Error("not a spec");
449
+ } catch {
450
+ return STATIC_API(
451
+ "fail",
452
+ "endpoint returned 200 but not an OpenAPI document \u2014 proxy or captive portal in the way?"
453
+ );
454
+ }
455
+ return STATIC_API("pass", "OpenAPI spec reachable");
456
+ }
457
+ async function checkBoardResolves(fetchImpl, env) {
458
+ const origin = new URL(env.apiUrl).origin;
459
+ const result = await probe(
460
+ fetchImpl,
461
+ `${origin}/v1/boards/${encodeURIComponent(env.boardKey)}`
462
+ );
463
+ if (!result.ok) {
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
+ };
471
+ }
472
+ let parsed = null;
473
+ try {
474
+ parsed = JSON.parse(result.body);
475
+ if (typeof parsed !== "object" || parsed === null) throw new Error("shape");
476
+ } catch {
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
+ };
484
+ }
485
+ return {
486
+ result: STATIC_BOARD("pass", "publishable key resolves the board"),
487
+ context: parsed
488
+ };
489
+ }
490
+ var SKILL_ROOTS = [".claude/skills", ".agents/skills"];
491
+ function checkSkillsFreshness(projectRoot) {
492
+ const roots = SKILL_ROOTS.map((root) => join(projectRoot, root)).filter(
493
+ (root) => existsSync(root)
494
+ );
495
+ if (roots.length === 0) {
496
+ return STATIC_SKILLS(
497
+ "skip",
498
+ "no .claude/skills or .agents/skills directory \u2014 run `npx @cavuno/board setup` to install agent skills"
499
+ );
500
+ }
501
+ const corpus = loadSkillCorpus();
502
+ const stale = /* @__PURE__ */ new Set();
503
+ const seen = /* @__PURE__ */ new Set();
504
+ for (const root of roots) {
505
+ for (const skill of corpus.skills) {
506
+ const copied = join(root, skill.name, "SKILL.md");
507
+ if (!existsSync(copied)) continue;
508
+ seen.add(skill.name);
509
+ if (readFileSync2(copied, "utf8") !== skill.content) {
510
+ stale.add(skill.name);
511
+ }
512
+ }
513
+ }
514
+ const found = seen.size;
515
+ if (found === 0) {
516
+ return STATIC_SKILLS(
517
+ "skip",
518
+ "no cavuno-board-* skills installed \u2014 run `npx @cavuno/board setup`"
519
+ );
520
+ }
521
+ return stale.size === 0 ? STATIC_SKILLS(
522
+ "pass",
523
+ `${found} installed skills match v${corpus.version}`
524
+ ) : STATIC_SKILLS(
525
+ "fail",
526
+ `stale skills (re-run \`npx @cavuno/board setup\`): ${[...stale].join(", ")}`
527
+ );
528
+ }
529
+ async function runDoctor(options) {
530
+ const fetchImpl = options.fetchImpl ?? fetch;
531
+ const results = [];
532
+ const envResults = checkEnv(options.env);
533
+ results.push(...envResults);
534
+ const envOk = envResults.every((r) => r.status === "pass");
535
+ if (options.env.apiUrl) {
536
+ const api = await checkApiReachable(fetchImpl, options.env.apiUrl);
537
+ results.push(
538
+ api ?? STATIC_API("skip", "API URL malformed \u2014 fix env.api-url first")
539
+ );
540
+ } else {
541
+ results.push(STATIC_API("skip", "PUBLIC_CAVUNO_API_URL not set"));
542
+ }
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"));
550
+ }
551
+ results.push(checkSkillsFreshness(options.projectRoot ?? process.cwd()));
552
+ results.push(
553
+ ...options.frontendUrl ? await runReadProbes(fetchImpl, options.frontendUrl) : skipReadProbes("no --frontend <url> given")
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
+ }
574
+ return { results, summary: summarize(results) };
575
+ }
29
576
 
30
577
  // src/setup/run.ts
31
- import { cpSync, existsSync, mkdirSync, readFileSync as readFileSync2 } from "fs";
578
+ import { cpSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3 } from "fs";
32
579
  import { dirname as dirname2, resolve as resolve2 } from "path";
33
580
  function detectFramework(cwd) {
34
581
  const pkgPath = resolve2(cwd, "package.json");
35
- if (!existsSync(pkgPath)) return null;
36
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
582
+ if (!existsSync2(pkgPath)) return null;
583
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
37
584
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
38
585
  if (deps["@tanstack/react-start"]) return "tanstack-start";
39
586
  return null;
@@ -48,7 +595,7 @@ function runSetup(cwd = process.cwd()) {
48
595
  resolve2(cwd, ".claude", "skills"),
49
596
  resolve2(cwd, ".agents", "skills")
50
597
  ];
51
- const existing = roots.filter((root) => existsSync(root));
598
+ const existing = roots.filter((root) => existsSync2(root));
52
599
  const targetDirs = existing.length > 0 ? existing : [roots[0]];
53
600
  const copied = [];
54
601
  for (const targetDir of targetDirs) {
@@ -63,45 +610,76 @@ function runSetup(cwd = process.cwd()) {
63
610
  }
64
611
  return { version: manifest.version, framework, targetDirs, copied };
65
612
  }
66
- var init_run = __esm({
67
- "src/setup/run.ts"() {
68
- "use strict";
69
- init_skills();
70
- }
71
- });
72
613
 
73
614
  // src/bin.ts
74
- var require_bin = __commonJS({
75
- "src/bin.ts"() {
76
- init_run();
77
- function main() {
78
- if (process.argv[2] !== "setup") {
79
- console.error("Usage: cavuno-board setup");
80
- process.exit(1);
81
- }
82
- const result = runSetup();
83
- console.log(`
84
- @cavuno/board setup \u2014 v${result.version}`);
85
- console.log(
86
- `Detected framework: ${result.framework ?? "none (core skills only)"}`
87
- );
88
- console.log(
89
- `Copied ${result.copied.length} skills \u2192 ${result.targetDirs.join(", ")}`
90
- );
91
- for (const name of result.copied) console.log(` - ${name}`);
92
- console.log("\nNext steps:");
93
- console.log(" 1. Set your environment variables:");
94
- console.log(" PUBLIC_CAVUNO_API_URL=https://api.cavuno.com");
95
- console.log(
96
- " PUBLIC_CAVUNO_BOARD=pk_... # your board publishable key"
97
- );
98
- console.log(' 2. Ask your coding agent: "set up my Cavuno board".');
99
- console.log(" It reads the cavuno-board-setup skill first.");
100
- console.log(
101
- "\n Re-run `npx @cavuno/board setup` after upgrading to refresh skills."
102
- );
103
- }
104
- main();
615
+ async function doctor(argv) {
616
+ const frontendFlag = argv.indexOf("--frontend");
617
+ const frontendUrl = frontendFlag >= 0 ? argv[frontendFlag + 1] : void 0;
618
+ const { results, summary } = await runDoctor({
619
+ env: {
620
+ apiUrl: process.env.PUBLIC_CAVUNO_API_URL,
621
+ boardKey: process.env.PUBLIC_CAVUNO_BOARD
622
+ },
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
628
+ });
629
+ console.log("\n@cavuno/board doctor");
630
+ for (const result of results) {
631
+ const mark = result.status === "pass" ? "\u2713" : result.status === "fail" ? "\u2717" : "\u2212";
632
+ console.log(
633
+ ` ${mark} [tier ${result.tier}] ${result.id} \u2014 ${result.detail}`
634
+ );
635
+ }
636
+ console.log(
637
+ `
638
+ ${summary.passed.length} passed, ${summary.failed.length} failed, ${summary.skipped.length} skipped`
639
+ );
640
+ if (summary.skipped.length > 0) {
641
+ console.log(` Skipped (NOT verified): ${summary.skipped.join(", ")}`);
642
+ }
643
+ if (!frontendUrl) {
644
+ console.log(
645
+ " Pass --frontend <url> to run the read probes against your board frontend."
646
+ );
647
+ }
648
+ process.exit(summary.exitCode);
649
+ }
650
+ function main() {
651
+ const verb = process.argv[2];
652
+ if (verb === "doctor") {
653
+ doctor(process.argv.slice(3)).catch((error) => {
654
+ console.error("doctor crashed:", error);
655
+ process.exit(1);
656
+ });
657
+ return;
658
+ }
659
+ if (verb !== "setup") {
660
+ console.error("Usage: cavuno-board <setup|doctor>");
661
+ process.exit(1);
105
662
  }
106
- });
107
- export default require_bin();
663
+ const result = runSetup();
664
+ console.log(`
665
+ @cavuno/board setup \u2014 v${result.version}`);
666
+ console.log(
667
+ `Detected framework: ${result.framework ?? "none (core skills only)"}`
668
+ );
669
+ console.log(
670
+ `Copied ${result.copied.length} skills \u2192 ${result.targetDirs.join(", ")}`
671
+ );
672
+ for (const name of result.copied) console.log(` - ${name}`);
673
+ console.log("\nNext steps:");
674
+ console.log(" 1. Set your environment variables:");
675
+ console.log(" PUBLIC_CAVUNO_API_URL=https://api.cavuno.com");
676
+ console.log(
677
+ " PUBLIC_CAVUNO_BOARD=pk_... # your board publishable key"
678
+ );
679
+ console.log(' 2. Ask your coding agent: "set up my Cavuno board".');
680
+ console.log(" It reads the cavuno-board-setup skill first.");
681
+ console.log(
682
+ "\n Re-run `npx @cavuno/board setup` after upgrading to refresh skills."
683
+ );
684
+ }
685
+ main();
@@ -1,4 +1,4 @@
1
- import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-DK5mPBgq.mjs';
1
+ import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-lzeoVGY6.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-DK5mPBgq.js';
1
+ import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-lzeoVGY6.js';
2
2
 
3
3
  declare const REMOTE_OPTIONS: readonly RemoteOption[];
4
4
  /**
package/dist/format.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { P as PublicJob, a as PublicJobCard } from './jobs-DK5mPBgq.mjs';
1
+ import { P as PublicJob, a as PublicJobCard } from './jobs-lzeoVGY6.mjs';
2
2
 
3
3
  /**
4
4
  * Date display helpers in the board language (required leading parameter,
package/dist/format.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { P as PublicJob, a as PublicJobCard } from './jobs-DK5mPBgq.js';
1
+ import { P as PublicJob, a as PublicJobCard } from './jobs-lzeoVGY6.js';
2
2
 
3
3
  /**
4
4
  * Date display helpers in the board language (required leading parameter,
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
- import { b as Schemas, R as RemoteOption, E as EmploymentType, S as Seniority, L as ListEnvelope, c as components, d as JobsListQuery, e as JobCardListEnvelope, f as JobsSearchBody, g as JobCardSearchEnvelope, h as JobsSimilarQuery, i as SearchEnvelope } from './jobs-DK5mPBgq.mjs';
2
- export { C as CustomFieldValue, j as CustomFieldValues, k as EducationRequirement, l as JobCompany, J as JobSort, O as OfficeLocation, P as PublicJob, a as PublicJobCard, m as RelatedSearch, n as RemotePermit, o as RemoteTimezone, p as StorefrontPagination } from './jobs-DK5mPBgq.mjs';
3
- import { c as CompaniesListQuery, d as CompanyListEnvelope, e as CompaniesSearchBody, f as CompanyJobsListQuery, g as CompanySimilarQuery, h as CompanyMarketsListQuery, i as SalaryDetailQuery, j as BlogPostsListQuery, k as BlogSimilarQuery, l as BlogSearchBody } from './salaries-CrJsaZe6.mjs';
4
- export { B as BlogAuthorEmbed, m as BlogTagEmbed, C as CompanyCategorySalary, n as CompanyMarket, o as CompanyMarketRef, b as CompanySalary, p as CustomFieldDefinition, q as CustomFieldOption, r as CustomFieldType, L as LocationSalaryDetail, s as LocationSkillsIndex, t as LocationTitlesIndex, u as PublicBlogAdjacentPosts, v as PublicBlogAuthor, w as PublicBlogPost, a as PublicBlogPostSummary, x as PublicBlogTag, P as PublicBoard, y as PublicBoardAnalytics, z as PublicBoardFeatures, A as PublicBoardTheme, D as PublicCompany, E as PublicCompanyDetail, F as SalaryCompany, G as SalaryLocation, H as SalarySkill, I as SalaryTitle, J as SkillLocationSalary, K as SkillLocationsIndex, S as SkillSalaryDetail, M as TitleLocationSalary, N as TitleLocationsIndex, T as TitleSalaryDetail } from './salaries-CrJsaZe6.mjs';
1
+ import { b as Schemas, R as RemoteOption, E as EmploymentType, S as Seniority, L as ListEnvelope, c as components, d as JobsListQuery, e as JobCardListEnvelope, f as JobsSearchBody, g as JobCardSearchEnvelope, h as JobsSimilarQuery, i as SearchEnvelope } from './jobs-lzeoVGY6.mjs';
2
+ export { C as CustomFieldValue, j as CustomFieldValues, k as EducationRequirement, l as JobCompany, J as JobSort, O as OfficeLocation, P as PublicJob, a as PublicJobCard, m as RelatedSearch, n as RemotePermit, o as RemoteTimezone, p as StorefrontPagination } from './jobs-lzeoVGY6.mjs';
3
+ import { c as CompaniesListQuery, d as CompanyListEnvelope, e as CompaniesSearchBody, f as CompanyJobsListQuery, g as CompanySimilarQuery, h as CompanyMarketsListQuery, i as SalaryDetailQuery, j as BlogPostsListQuery, k as BlogSimilarQuery, l as BlogSearchBody } from './salaries-DpH5CxdM.mjs';
4
+ export { B as BlogAuthorEmbed, m as BlogTagEmbed, C as CompanyCategorySalary, n as CompanyMarket, o as CompanyMarketRef, b as CompanySalary, p as CustomFieldDefinition, q as CustomFieldOption, r as CustomFieldType, L as LocationSalaryDetail, s as LocationSkillsIndex, t as LocationTitlesIndex, u as PublicBlogAdjacentPosts, v as PublicBlogAuthor, w as PublicBlogPost, a as PublicBlogPostSummary, x as PublicBlogTag, P as PublicBoard, y as PublicBoardAnalytics, z as PublicBoardFeatures, A as PublicBoardTheme, D as PublicCompany, E as PublicCompanyDetail, F as SalaryCompany, G as SalaryLocation, H as SalarySkill, I as SalaryTitle, J as SkillLocationSalary, K as SkillLocationsIndex, S as SkillSalaryDetail, M as TitleLocationSalary, N as TitleLocationsIndex, T as TitleSalaryDetail } from './salaries-DpH5CxdM.mjs';
5
5
 
6
6
  type Awaitable<T> = T | Promise<T>;
7
7
  /**
@@ -203,7 +203,7 @@ declare function paginate<Q extends Record<string, unknown>, P extends PageShape
203
203
  * constant because the package is platform-neutral and cannot read
204
204
  * package.json at runtime.
205
205
  */
206
- declare const SDK_VERSION = "1.28.1";
206
+ declare const SDK_VERSION = "1.30.0";
207
207
 
208
208
  type SavedJob = Schemas['SavedJob'];
209
209
  type SavedJobsListQuery = {
@@ -654,6 +654,7 @@ declare function createBoardClient(options: CreateBoardClientOptions): {
654
654
  logoUrl: string | null;
655
655
  primaryDomain: string | null;
656
656
  showCavunoBranding: boolean;
657
+ sandbox: boolean;
657
658
  features: {
658
659
  jobAlerts: boolean;
659
660
  candidates: boolean;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { b as Schemas, R as RemoteOption, E as EmploymentType, S as Seniority, L as ListEnvelope, c as components, d as JobsListQuery, e as JobCardListEnvelope, f as JobsSearchBody, g as JobCardSearchEnvelope, h as JobsSimilarQuery, i as SearchEnvelope } from './jobs-DK5mPBgq.js';
2
- export { C as CustomFieldValue, j as CustomFieldValues, k as EducationRequirement, l as JobCompany, J as JobSort, O as OfficeLocation, P as PublicJob, a as PublicJobCard, m as RelatedSearch, n as RemotePermit, o as RemoteTimezone, p as StorefrontPagination } from './jobs-DK5mPBgq.js';
3
- import { c as CompaniesListQuery, d as CompanyListEnvelope, e as CompaniesSearchBody, f as CompanyJobsListQuery, g as CompanySimilarQuery, h as CompanyMarketsListQuery, i as SalaryDetailQuery, j as BlogPostsListQuery, k as BlogSimilarQuery, l as BlogSearchBody } from './salaries-CXt6Vkrp.js';
4
- export { B as BlogAuthorEmbed, m as BlogTagEmbed, C as CompanyCategorySalary, n as CompanyMarket, o as CompanyMarketRef, b as CompanySalary, p as CustomFieldDefinition, q as CustomFieldOption, r as CustomFieldType, L as LocationSalaryDetail, s as LocationSkillsIndex, t as LocationTitlesIndex, u as PublicBlogAdjacentPosts, v as PublicBlogAuthor, w as PublicBlogPost, a as PublicBlogPostSummary, x as PublicBlogTag, P as PublicBoard, y as PublicBoardAnalytics, z as PublicBoardFeatures, A as PublicBoardTheme, D as PublicCompany, E as PublicCompanyDetail, F as SalaryCompany, G as SalaryLocation, H as SalarySkill, I as SalaryTitle, J as SkillLocationSalary, K as SkillLocationsIndex, S as SkillSalaryDetail, M as TitleLocationSalary, N as TitleLocationsIndex, T as TitleSalaryDetail } from './salaries-CXt6Vkrp.js';
1
+ import { b as Schemas, R as RemoteOption, E as EmploymentType, S as Seniority, L as ListEnvelope, c as components, d as JobsListQuery, e as JobCardListEnvelope, f as JobsSearchBody, g as JobCardSearchEnvelope, h as JobsSimilarQuery, i as SearchEnvelope } from './jobs-lzeoVGY6.js';
2
+ export { C as CustomFieldValue, j as CustomFieldValues, k as EducationRequirement, l as JobCompany, J as JobSort, O as OfficeLocation, P as PublicJob, a as PublicJobCard, m as RelatedSearch, n as RemotePermit, o as RemoteTimezone, p as StorefrontPagination } from './jobs-lzeoVGY6.js';
3
+ import { c as CompaniesListQuery, d as CompanyListEnvelope, e as CompaniesSearchBody, f as CompanyJobsListQuery, g as CompanySimilarQuery, h as CompanyMarketsListQuery, i as SalaryDetailQuery, j as BlogPostsListQuery, k as BlogSimilarQuery, l as BlogSearchBody } from './salaries-Cy3MhZov.js';
4
+ export { B as BlogAuthorEmbed, m as BlogTagEmbed, C as CompanyCategorySalary, n as CompanyMarket, o as CompanyMarketRef, b as CompanySalary, p as CustomFieldDefinition, q as CustomFieldOption, r as CustomFieldType, L as LocationSalaryDetail, s as LocationSkillsIndex, t as LocationTitlesIndex, u as PublicBlogAdjacentPosts, v as PublicBlogAuthor, w as PublicBlogPost, a as PublicBlogPostSummary, x as PublicBlogTag, P as PublicBoard, y as PublicBoardAnalytics, z as PublicBoardFeatures, A as PublicBoardTheme, D as PublicCompany, E as PublicCompanyDetail, F as SalaryCompany, G as SalaryLocation, H as SalarySkill, I as SalaryTitle, J as SkillLocationSalary, K as SkillLocationsIndex, S as SkillSalaryDetail, M as TitleLocationSalary, N as TitleLocationsIndex, T as TitleSalaryDetail } from './salaries-Cy3MhZov.js';
5
5
 
6
6
  type Awaitable<T> = T | Promise<T>;
7
7
  /**
@@ -203,7 +203,7 @@ declare function paginate<Q extends Record<string, unknown>, P extends PageShape
203
203
  * constant because the package is platform-neutral and cannot read
204
204
  * package.json at runtime.
205
205
  */
206
- declare const SDK_VERSION = "1.28.1";
206
+ declare const SDK_VERSION = "1.30.0";
207
207
 
208
208
  type SavedJob = Schemas['SavedJob'];
209
209
  type SavedJobsListQuery = {
@@ -654,6 +654,7 @@ declare function createBoardClient(options: CreateBoardClientOptions): {
654
654
  logoUrl: string | null;
655
655
  primaryDomain: string | null;
656
656
  showCavunoBranding: boolean;
657
+ sandbox: boolean;
657
658
  features: {
658
659
  jobAlerts: boolean;
659
660
  candidates: boolean;
package/dist/index.js CHANGED
@@ -310,7 +310,7 @@ async function clearSession(storage) {
310
310
  }
311
311
 
312
312
  // src/version.ts
313
- var SDK_VERSION = "1.28.1";
313
+ var SDK_VERSION = "1.30.0";
314
314
 
315
315
  // src/client.ts
316
316
  function isRawBody(body) {
package/dist/index.mjs CHANGED
@@ -265,7 +265,7 @@ async function clearSession(storage) {
265
265
  }
266
266
 
267
267
  // src/version.ts
268
- var SDK_VERSION = "1.28.1";
268
+ var SDK_VERSION = "1.30.0";
269
269
 
270
270
  // src/client.ts
271
271
  function isRawBody(body) {
@@ -2382,6 +2382,8 @@ interface components {
2382
2382
  primaryDomain: string | null;
2383
2383
  /** @description Whitelabel toggle (default `true`). Render the "Powered by Cavuno" badge unless `false`. */
2384
2384
  showCavunoBranding: boolean;
2385
+ /** @description True ONLY for the platform sandbox fixture tenant — the safe target for write probes (doctor tier 3); data is reset nightly. Every real tenant board is `false`. */
2386
+ sandbox: boolean;
2385
2387
  features: {
2386
2388
  jobAlerts: boolean;
2387
2389
  candidates: boolean;
@@ -2382,6 +2382,8 @@ interface components {
2382
2382
  primaryDomain: string | null;
2383
2383
  /** @description Whitelabel toggle (default `true`). Render the "Powered by Cavuno" badge unless `false`. */
2384
2384
  showCavunoBranding: boolean;
2385
+ /** @description True ONLY for the platform sandbox fixture tenant — the safe target for write probes (doctor tier 3); data is reset nightly. Every real tenant board is `false`. */
2386
+ sandbox: boolean;
2385
2387
  features: {
2386
2388
  jobAlerts: boolean;
2387
2389
  candidates: boolean;
@@ -1,4 +1,4 @@
1
- import { b as Schemas, L as ListEnvelope, m as RelatedSearch } from './jobs-DK5mPBgq.js';
1
+ import { b as Schemas, L as ListEnvelope, m as RelatedSearch } from './jobs-lzeoVGY6.js';
2
2
 
3
3
  type PublicBoard = Schemas['PublicBoardContext'];
4
4
  type PublicBoardFeatures = PublicBoard['features'];
@@ -1,4 +1,4 @@
1
- import { b as Schemas, L as ListEnvelope, m as RelatedSearch } from './jobs-DK5mPBgq.mjs';
1
+ import { b as Schemas, L as ListEnvelope, m as RelatedSearch } from './jobs-lzeoVGY6.mjs';
2
2
 
3
3
  type PublicBoard = Schemas['PublicBoardContext'];
4
4
  type PublicBoardFeatures = PublicBoard['features'];
package/dist/seo.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as PublicBoard, a as PublicBlogPostSummary, B as BlogAuthorEmbed, C as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './salaries-CrJsaZe6.mjs';
2
- import { P as PublicJob } from './jobs-DK5mPBgq.mjs';
1
+ import { P as PublicBoard, a as PublicBlogPostSummary, B as BlogAuthorEmbed, C as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './salaries-DpH5CxdM.mjs';
2
+ import { P as PublicJob } from './jobs-lzeoVGY6.mjs';
3
3
 
4
4
  /**
5
5
  * Google for Jobs `JobPosting` structured data on the `@cavuno/board` wire
package/dist/seo.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as PublicBoard, a as PublicBlogPostSummary, B as BlogAuthorEmbed, C as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './salaries-CXt6Vkrp.js';
2
- import { P as PublicJob } from './jobs-DK5mPBgq.js';
1
+ import { P as PublicBoard, a as PublicBlogPostSummary, B as BlogAuthorEmbed, C as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './salaries-Cy3MhZov.js';
2
+ import { P as PublicJob } from './jobs-lzeoVGY6.js';
3
3
 
4
4
  /**
5
5
  * Google for Jobs `JobPosting` structured data on the `@cavuno/board` wire
package/dist/server.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BoardSdk } from './index.mjs';
2
- import './jobs-DK5mPBgq.mjs';
3
- import './salaries-CrJsaZe6.mjs';
2
+ import './jobs-lzeoVGY6.mjs';
3
+ import './salaries-DpH5CxdM.mjs';
4
4
 
5
5
  /**
6
6
  * Session cookie codec — pure (no framework imports, no node imports) so it
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BoardSdk } from './index.js';
2
- import './jobs-DK5mPBgq.js';
3
- import './salaries-CXt6Vkrp.js';
2
+ import './jobs-lzeoVGY6.js';
3
+ import './salaries-Cy3MhZov.js';
4
4
 
5
5
  /**
6
6
  * Session cookie codec — pure (no framework imports, no node imports) so it
@@ -1,6 +1,6 @@
1
1
  import { BoardSdk } from './index.mjs';
2
- import './jobs-DK5mPBgq.mjs';
3
- import './salaries-CrJsaZe6.mjs';
2
+ import './jobs-lzeoVGY6.mjs';
3
+ import './salaries-DpH5CxdM.mjs';
4
4
 
5
5
  /**
6
6
  * Sitemap primitives — the pure XML + bucket-filename logic behind a board
package/dist/sitemap.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BoardSdk } from './index.js';
2
- import './jobs-DK5mPBgq.js';
3
- import './salaries-CXt6Vkrp.js';
2
+ import './jobs-lzeoVGY6.js';
3
+ import './salaries-Cy3MhZov.js';
4
4
 
5
5
  /**
6
6
  * Sitemap primitives — the pure XML + bucket-filename logic behind a board
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Skill-manifest types, split from `skills.ts` so non-Node consumers (the
3
+ * remote-mcp worker types its `/dev` toolset against the wire shape) can
4
+ * import them without dragging the Node fs loader into their program.
5
+ */
6
+ interface SkillManifestEntry {
7
+ name: string;
8
+ description: string;
9
+ /** Path to the SKILL.md, relative to the package root. */
10
+ path: string;
11
+ /** Framework slug for flavor skills; `null` for framework-agnostic core skills. */
12
+ framework: string | null;
13
+ category: 'core' | 'flavor';
14
+ }
15
+ interface SkillManifest {
16
+ version: string;
17
+ skills: SkillManifestEntry[];
18
+ }
19
+
20
+ export type { SkillManifest, SkillManifestEntry };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Skill-manifest types, split from `skills.ts` so non-Node consumers (the
3
+ * remote-mcp worker types its `/dev` toolset against the wire shape) can
4
+ * import them without dragging the Node fs loader into their program.
5
+ */
6
+ interface SkillManifestEntry {
7
+ name: string;
8
+ description: string;
9
+ /** Path to the SKILL.md, relative to the package root. */
10
+ path: string;
11
+ /** Framework slug for flavor skills; `null` for framework-agnostic core skills. */
12
+ framework: string | null;
13
+ category: 'core' | 'flavor';
14
+ }
15
+ interface SkillManifest {
16
+ version: string;
17
+ skills: SkillManifestEntry[];
18
+ }
19
+
20
+ export type { SkillManifest, SkillManifestEntry };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __copyProps = (to, from, except, desc) => {
7
+ if (from && typeof from === "object" || typeof from === "function") {
8
+ for (let key of __getOwnPropNames(from))
9
+ if (!__hasOwnProp.call(to, key) && key !== except)
10
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
11
+ }
12
+ return to;
13
+ };
14
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
15
+
16
+ // src/skills/types.ts
17
+ var types_exports = {};
18
+ module.exports = __toCommonJS(types_exports);
File without changes
package/dist/skills.d.mts CHANGED
@@ -1,9 +1,7 @@
1
1
  /**
2
- * Skill-corpus loader. Node-only (reads the shipped `skills/` directory from
3
- * the installed package) kept out of the isomorphic core and exposed via the
4
- * `@cavuno/board/skills` subpath export. Both `npx @cavuno/board setup` and the
5
- * in-admin sidekick (ADR-0033) read the corpus through here, so the two doors
6
- * stay fed from one source.
2
+ * Skill-manifest types, split from `skills.ts` so non-Node consumers (the
3
+ * remote-mcp worker types its `/dev` toolset against the wire shape) can
4
+ * import them without dragging the Node fs loader into their program.
7
5
  */
8
6
  interface SkillManifestEntry {
9
7
  name: string;
@@ -18,6 +16,15 @@ interface SkillManifest {
18
16
  version: string;
19
17
  skills: SkillManifestEntry[];
20
18
  }
19
+
20
+ /**
21
+ * Skill-corpus loader. Node-only (reads the shipped `skills/` directory from
22
+ * the installed package) — kept out of the isomorphic core and exposed via the
23
+ * `@cavuno/board/skills` subpath export. Both `npx @cavuno/board setup` and the
24
+ * in-admin sidekick (ADR-0033) read the corpus through here, so the two doors
25
+ * stay fed from one source.
26
+ */
27
+
21
28
  interface LoadedSkill extends SkillManifestEntry {
22
29
  content: string;
23
30
  }
@@ -26,13 +33,13 @@ interface SkillCorpus {
26
33
  skills: LoadedSkill[];
27
34
  }
28
35
  /** Resolve a package-root-relative path (e.g. a manifest `path`) to an absolute path. */
29
- declare function resolveFromPackageRoot(relativePath: string): string;
30
- declare function loadSkillManifest(): SkillManifest;
36
+ declare function resolveFromPackageRoot(relativePath: string, baseDir?: string): string;
37
+ declare function loadSkillManifest(baseDir?: string): SkillManifest;
31
38
  /**
32
39
  * Load the full skill corpus — manifest metadata plus each skill's markdown
33
40
  * `content`. The sidekick injects `content` into its agent context; an external
34
41
  * Claude Code instead receives the files copied by the setup command.
35
42
  */
36
- declare function loadSkillCorpus(): SkillCorpus;
43
+ declare function loadSkillCorpus(baseDir?: string): SkillCorpus;
37
44
 
38
45
  export { type LoadedSkill, type SkillCorpus, type SkillManifest, type SkillManifestEntry, loadSkillCorpus, loadSkillManifest, resolveFromPackageRoot };
package/dist/skills.d.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  /**
2
- * Skill-corpus loader. Node-only (reads the shipped `skills/` directory from
3
- * the installed package) kept out of the isomorphic core and exposed via the
4
- * `@cavuno/board/skills` subpath export. Both `npx @cavuno/board setup` and the
5
- * in-admin sidekick (ADR-0033) read the corpus through here, so the two doors
6
- * stay fed from one source.
2
+ * Skill-manifest types, split from `skills.ts` so non-Node consumers (the
3
+ * remote-mcp worker types its `/dev` toolset against the wire shape) can
4
+ * import them without dragging the Node fs loader into their program.
7
5
  */
8
6
  interface SkillManifestEntry {
9
7
  name: string;
@@ -18,6 +16,15 @@ interface SkillManifest {
18
16
  version: string;
19
17
  skills: SkillManifestEntry[];
20
18
  }
19
+
20
+ /**
21
+ * Skill-corpus loader. Node-only (reads the shipped `skills/` directory from
22
+ * the installed package) — kept out of the isomorphic core and exposed via the
23
+ * `@cavuno/board/skills` subpath export. Both `npx @cavuno/board setup` and the
24
+ * in-admin sidekick (ADR-0033) read the corpus through here, so the two doors
25
+ * stay fed from one source.
26
+ */
27
+
21
28
  interface LoadedSkill extends SkillManifestEntry {
22
29
  content: string;
23
30
  }
@@ -26,13 +33,13 @@ interface SkillCorpus {
26
33
  skills: LoadedSkill[];
27
34
  }
28
35
  /** Resolve a package-root-relative path (e.g. a manifest `path`) to an absolute path. */
29
- declare function resolveFromPackageRoot(relativePath: string): string;
30
- declare function loadSkillManifest(): SkillManifest;
36
+ declare function resolveFromPackageRoot(relativePath: string, baseDir?: string): string;
37
+ declare function loadSkillManifest(baseDir?: string): SkillManifest;
31
38
  /**
32
39
  * Load the full skill corpus — manifest metadata plus each skill's markdown
33
40
  * `content`. The sidekick injects `content` into its agent context; an external
34
41
  * Claude Code instead receives the files copied by the setup command.
35
42
  */
36
- declare function loadSkillCorpus(): SkillCorpus;
43
+ declare function loadSkillCorpus(baseDir?: string): SkillCorpus;
37
44
 
38
45
  export { type LoadedSkill, type SkillCorpus, type SkillManifest, type SkillManifestEntry, loadSkillCorpus, loadSkillManifest, resolveFromPackageRoot };
package/dist/skills.js CHANGED
@@ -37,20 +37,23 @@ var import_node_url = require("url");
37
37
  function packageRoot() {
38
38
  return (0, import_node_path.resolve)((0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl)), "..");
39
39
  }
40
- function resolveFromPackageRoot(relativePath) {
41
- return (0, import_node_path.resolve)(packageRoot(), relativePath);
40
+ function resolveFromPackageRoot(relativePath, baseDir) {
41
+ return (0, import_node_path.resolve)(baseDir ?? packageRoot(), relativePath);
42
42
  }
43
- function loadSkillManifest() {
44
- const manifestPath = resolveFromPackageRoot("skills/manifest.json");
43
+ function loadSkillManifest(baseDir) {
44
+ const manifestPath = resolveFromPackageRoot("skills/manifest.json", baseDir);
45
45
  return JSON.parse((0, import_node_fs.readFileSync)(manifestPath, "utf8"));
46
46
  }
47
- function loadSkillCorpus() {
48
- const manifest = loadSkillManifest();
47
+ function loadSkillCorpus(baseDir) {
48
+ const manifest = loadSkillManifest(baseDir);
49
49
  return {
50
50
  version: manifest.version,
51
51
  skills: manifest.skills.map((skill) => ({
52
52
  ...skill,
53
- content: (0, import_node_fs.readFileSync)(resolveFromPackageRoot(skill.path), "utf8")
53
+ content: (0, import_node_fs.readFileSync)(
54
+ resolveFromPackageRoot(skill.path, baseDir),
55
+ "utf8"
56
+ )
54
57
  }))
55
58
  };
56
59
  }
package/dist/skills.mjs CHANGED
@@ -5,20 +5,23 @@ import { fileURLToPath } from "url";
5
5
  function packageRoot() {
6
6
  return resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
7
  }
8
- function resolveFromPackageRoot(relativePath) {
9
- return resolve(packageRoot(), relativePath);
8
+ function resolveFromPackageRoot(relativePath, baseDir) {
9
+ return resolve(baseDir ?? packageRoot(), relativePath);
10
10
  }
11
- function loadSkillManifest() {
12
- const manifestPath = resolveFromPackageRoot("skills/manifest.json");
11
+ function loadSkillManifest(baseDir) {
12
+ const manifestPath = resolveFromPackageRoot("skills/manifest.json", baseDir);
13
13
  return JSON.parse(readFileSync(manifestPath, "utf8"));
14
14
  }
15
- function loadSkillCorpus() {
16
- const manifest = loadSkillManifest();
15
+ function loadSkillCorpus(baseDir) {
16
+ const manifest = loadSkillManifest(baseDir);
17
17
  return {
18
18
  version: manifest.version,
19
19
  skills: manifest.skills.map((skill) => ({
20
20
  ...skill,
21
- content: readFileSync(resolveFromPackageRoot(skill.path), "utf8")
21
+ content: readFileSync(
22
+ resolveFromPackageRoot(skill.path, baseDir),
23
+ "utf8"
24
+ )
22
25
  }))
23
26
  };
24
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "1.28.1",
3
+ "version": "1.30.0",
4
4
  "description": "Typed isomorphic client for the Cavuno Board API",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -100,6 +100,16 @@
100
100
  "default": "./dist/skills.js"
101
101
  }
102
102
  },
103
+ "./skills/types": {
104
+ "import": {
105
+ "types": "./dist/skills-types.d.mts",
106
+ "default": "./dist/skills-types.mjs"
107
+ },
108
+ "require": {
109
+ "types": "./dist/skills-types.d.ts",
110
+ "default": "./dist/skills-types.js"
111
+ }
112
+ },
103
113
  "./skills/*": "./skills/*"
104
114
  },
105
115
  "files": [
@@ -15,6 +15,34 @@ auth, and SEO surfaces behave. Verify at runtime, in this order.
15
15
  - After upgrading `@cavuno/board` or rotating the `pk_…` key.
16
16
  - When any surface renders empty and you don't know which layer is wrong.
17
17
 
18
+ ## 0 — Run `doctor` first (deterministic pass)
19
+
20
+ ```bash
21
+ PUBLIC_CAVUNO_API_URL=... PUBLIC_CAVUNO_BOARD=pk_... \
22
+ npx @cavuno/board doctor --frontend http://localhost:3000
23
+ ```
24
+
25
+ Doctor codifies the static checks (env shape, API reachability) and the
26
+ read probes (home/jobs render, JobPosting JSON-LD, sitemap, robots) as
27
+ pass/fail/skip with a non-zero exit on failure — anything it SKIPS is
28
+ named in its summary and still needs the manual checks below. Use the
29
+ rest of this skill for the behavioral checks doctor does not automate
30
+ (auth flows, gating semantics, production build).
31
+
32
+ Add `--sandbox` to also run the write probes (register → login → apply →
33
+ alert-signup). They are refused unless the board the `pk_` resolves is
34
+ THE platform sandbox (`sandbox: true` in the board context — set only by
35
+ the platform's sandbox seed and stripped from any client settings write)
36
+ — never point them at a real tenant. The
37
+ email-delivery check additionally needs `RESEND_API_KEY` (a platform
38
+ operator credential) and SKIPs loudly without it.
39
+
40
+ Rate limits are real on the sandbox: the auth endpoints share a
41
+ 10-requests/min bucket and register allows 5 signups per 15 minutes per
42
+ IP, and one probe run consumes 4 requests + 1 signup. Rapid reruns will
43
+ start failing with HTTP 429 — that's the limiter working, not a broken
44
+ board; wait a few minutes.
45
+
18
46
  ## 1 — Probe the API directly (before blaming app code)
19
47
 
20
48
  Use the real env values the app reads (`PUBLIC_CAVUNO_API_URL`,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.28.1",
2
+ "version": "1.30.0",
3
3
  "skills": [
4
4
  {
5
5
  "name": "cavuno-board-account",