@vercel/board 0.24.0 → 0.25.1

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.
Files changed (81) hide show
  1. package/dist/{a2-BxfKZFvJ.mjs → a2-A830f5NS.mjs} +6 -6
  2. package/dist/access.d.mts +1 -2
  3. package/dist/access.mjs +3 -3
  4. package/dist/{actors-rPnEQLZu.mjs → actors-BLXVp4oZ.mjs} +2 -2
  5. package/dist/{auth-5nLsx9qB.mjs → auth-lTkOgvcE.mjs} +1 -1
  6. package/dist/{backend-D0GDYiFr.d.mts → backend-ZKYTe3xa.d.mts} +5 -11
  7. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  8. package/dist/backend-a2/contract.d.mts +1 -2
  9. package/dist/backend-a2/store-memory.d.mts +1 -1
  10. package/dist/backend-a2/store-postgres.d.mts +1 -1
  11. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  12. package/dist/backend-a2/store-redis.d.mts +1 -1
  13. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  14. package/dist/backend-a2.d.mts +5 -6
  15. package/dist/backend-a2.mjs +1 -1
  16. package/dist/{backend-http-tGUo0e8j.mjs → backend-http-CJd4Fu0U.mjs} +2 -2
  17. package/dist/{backend-http-rfiG-AKP.mjs → backend-http-CYtfwqAW.mjs} +9 -6
  18. package/dist/{backend-http-Dvq2p__1.d.mts → backend-http-E7YU0qhn.d.mts} +3 -4
  19. package/dist/backend-http.d.mts +1 -1
  20. package/dist/backend-http.mjs +1 -1
  21. package/dist/backend-http.node.d.mts +1 -1
  22. package/dist/backend-http.node.mjs +1 -1
  23. package/dist/{backend-vercel-DM5PyMc2.mjs → backend-vercel-BhwPExmE.mjs} +3 -3
  24. package/dist/{backend-vercel-C3zuxmcD.mjs → backend-vercel-Bqg2QfTd.mjs} +7 -4
  25. package/dist/{backend-vercel-01QKDobk.d.mts → backend-vercel-CIRObA0t.d.mts} +3 -4
  26. package/dist/backend-vercel.d.mts +1 -1
  27. package/dist/backend-vercel.mjs +1 -1
  28. package/dist/backend-vercel.node.d.mts +1 -1
  29. package/dist/backend-vercel.node.mjs +1 -1
  30. package/dist/{blob-C0J3wbGv.d.mts → blob-B-9AvYis.d.mts} +2 -1
  31. package/dist/{chat-provider-D0k3y8zY.d.mts → chat-provider-DsNEBQiA.d.mts} +3 -2
  32. package/dist/cli-embedded.d.mts +55 -4
  33. package/dist/cli-embedded.mjs +38 -18
  34. package/dist/cli.mjs +4 -4
  35. package/dist/{client-ClznIBMg.mjs → client-D0Px7KzF.mjs} +26 -8
  36. package/dist/{provider-D11Dfda0.d.mts → client-xLxeDz0f.d.mts} +21 -688
  37. package/dist/{conformance-DQ9qVzWP.mjs → conformance-DlnfuzEB.mjs} +1 -1
  38. package/dist/conformance.d.mts +1 -1
  39. package/dist/conformance.mjs +1 -1
  40. package/dist/{engine-kHfSOXnQ.mjs → engine-ROa7-IQY.mjs} +2 -2
  41. package/dist/errors-CB4tnZGz.d.mts +16 -0
  42. package/dist/{errors-CQ-fwxM3.mjs → errors-CusN4x2l.mjs} +18 -6
  43. package/dist/exports-D22M68rk.d.mts +5 -0
  44. package/dist/{exports-Bw81oDDl.mjs → exports-DCqgO_mr.mjs} +6 -6
  45. package/dist/{folds-CRYY14kc.d.mts → folds-jS9wlh9_.d.mts} +49 -2
  46. package/dist/{index-B_hWanKF.d.mts → index-C05it0mC.d.mts} +2 -2
  47. package/dist/index.d.mts +5 -4
  48. package/dist/{index.default-B6oPlvEa.mjs → index.default-BejExznB.mjs} +1 -1
  49. package/dist/index.mjs +6 -6
  50. package/dist/index.node.d.mts +5 -4
  51. package/dist/index.node.mjs +4 -4
  52. package/dist/inspect/app.js +108 -108
  53. package/dist/{main-CzWvRy91.mjs → main-DOhNCWMd.mjs} +712 -142
  54. package/dist/{middleware-DUjyQP_a.mjs → middleware-ChKM1zuu.mjs} +1 -1
  55. package/dist/{node-CirFYqw0.mjs → node-Bkd1x0QI.mjs} +56 -28
  56. package/dist/provider-Cdg1qadh.d.mts +688 -0
  57. package/dist/{serve-DHyA-6FR.mjs → serve-D2O0C6Kk.mjs} +6 -5
  58. package/dist/{server-Cp2oIQOj.mjs → server-BF4WJ7qi.mjs} +4 -4
  59. package/dist/{serving-B307gppu.mjs → serving-CvOWEym2.mjs} +1 -1
  60. package/dist/sync-github.d.mts +2 -2
  61. package/dist/sync-kapso.d.mts +2 -2
  62. package/dist/sync-linear.d.mts +2 -2
  63. package/dist/sync-slack.d.mts +3 -3
  64. package/dist/sync.chat.d.mts +1 -1
  65. package/dist/sync.d.mts +2 -2
  66. package/dist/{tokens-DUXaXwOj.mjs → tokens-BuCU-61R.mjs} +2 -2
  67. package/dist/{types-8yq2VnKI.d.mts → types-aCAf6lGU.d.mts} +1 -1
  68. package/dist/{watchers-hash-C_1jGA53.mjs → watchers-hash-BHNXy_N7.mjs} +2 -2
  69. package/docs/01-board/03-identity.md +16 -7
  70. package/docs/01-board/04-messages.md +2 -2
  71. package/docs/01-board/05-reading.md +16 -8
  72. package/docs/01-board/07-schedules.md +2 -2
  73. package/docs/01-board/09-access.md +5 -4
  74. package/docs/03-guides/01-coding-agents.md +4 -2
  75. package/docs/03-guides/03-ci.md +2 -2
  76. package/docs/04-reference/01-cli.md +78 -19
  77. package/docs/04-reference/02-board.md +6 -1
  78. package/docs/04-reference/03-http.md +4 -0
  79. package/package.json +1 -1
  80. package/dist/exports-87_yESpp.d.mts +0 -4
  81. package/dist/schemas-CWxs3ECJ.d.mts +0 -50
@@ -1,5 +1,5 @@
1
1
  import { m as MESSAGE_TEXT_MAX, p as Labels } from "./schemas-Be08nyfW.mjs";
2
- import { t as HttpError } from "./errors-CQ-fwxM3.mjs";
2
+ import { t as HttpError } from "./errors-CusN4x2l.mjs";
3
3
  import { z } from "zod";
4
4
  //#region src/server/middleware.ts
5
5
  /** A write the middleware didn't let through: the API answers 422 with what it responded. */
@@ -1,4 +1,4 @@
1
- import { t as BoardError } from "./client-ClznIBMg.mjs";
1
+ import { t as BoardError } from "./client-D0Px7KzF.mjs";
2
2
  import { execFile } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -52,6 +52,13 @@ const LOOPBACK = /* @__PURE__ */ new Set([
52
52
  "localhost",
53
53
  "[::1]"
54
54
  ]);
55
+ const PROJECT_ID = /^prj_[A-Za-z0-9]{1,64}$/;
56
+ const URL_SHAPE = /^https?:\/\/[A-Za-z0-9.-]{1,253}(:\d{1,5})?(\/[A-Za-z0-9._~%/-]{0,2000})?$/;
57
+ /**
58
+ * A value a board said, as a message can show it: only when it's what it should be (a project
59
+ * ID, a URL), since the handle's messages are trusted where a board's words aren't.
60
+ */
61
+ const shown = (value, shape) => typeof value === "string" && shape.test(value) ? value : void 0;
55
62
  /** Where `vb serve` leaves the token for the board it runs at `url`: only this user can read it. */
56
63
  function localTokenFile(url) {
57
64
  const normalized = url.replace(/\/+$/, "");
@@ -99,7 +106,7 @@ function detectAgent(env = process.env) {
99
106
  profile: named,
100
107
  session: null
101
108
  });
102
- if (found.length > 1) throw new BoardError("ambiguous_agent", `several agent environments detected (${found.map((a) => a.profile).join(", ")}): choose one with a profile, or none`, 0);
109
+ if (found.length > 1) throw new BoardError("ambiguous_agent", `Several coding agents' environments are set (${found.map((a) => a.profile).join(", ")}), so it can't tell which one is writing.`, 0, { hint: "Pick one with a profile, or none to write as yourself." });
103
110
  return found[0] ?? null;
104
111
  }
105
112
  const expiry = (token) => {
@@ -157,7 +164,7 @@ async function personToken(audience, discovery) {
157
164
  return filled;
158
165
  }
159
166
  if (cached) return cached;
160
- if (!discovery.project_id) throw new BoardError("no_project", `the board at ${discovery.url} doesn't know its Vercel project`, 0);
167
+ if (!discovery.project_id) throw new BoardError("no_project", `The board at ${audience} doesn't know its Vercel project, so there's no Vercel login token to mint for it.`, 0, { hint: "Pass a token it accepts in BOARD_TOKEN." });
161
168
  const authToken = await vercelLogin().catch(() => null);
162
169
  const project = await boardProject(audience, discovery, authToken);
163
170
  let development;
@@ -167,7 +174,7 @@ async function personToken(audience, discovery) {
167
174
  method: "POST",
168
175
  headers: { authorization: `Bearer ${authToken}` }
169
176
  });
170
- if (response.status === 403 || response.status === 404) throw new BoardError("no_access", "your Vercel account can't access this board's project: ask for access to it", response.status);
177
+ if (response.status === 403 || response.status === 404) throw new BoardError("no_access", "Your Vercel account can't access this board's project.", response.status, { hint: "Ask someone on the project's team for access to it." });
171
178
  if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
172
179
  development = (await response.json()).token;
173
180
  } catch (error) {
@@ -210,8 +217,8 @@ async function boardProject(url, discovery, authToken) {
210
217
  });
211
218
  return response.ok ? await response.json() : null;
212
219
  } : vercelCliApi, host);
213
- if (!found) throw new BoardError("no_access", `your Vercel login can't see the project ${host} belongs to: run \`vercel login\` or ask for access to it`, 0);
214
- if (found.projectId !== discovery.project_id) throw new BoardError("wrong_project", `the board at ${url} says its project is ${discovery.project_id}, but it's a deployment of ${found.projectId}, so vb won't sign in to it`, 0);
220
+ if (!found) throw new BoardError("no_access", `Your Vercel login can't see the project ${host} belongs to.`, 0, { hint: "Log in as someone on the project's team (vercel login), or ask for access to it." });
221
+ if (found.projectId !== discovery.project_id) throw new BoardError("wrong_project", `The board at ${url} says its project is ${shown(discovery.project_id, PROJECT_ID) ?? "another one"}, but it's a deployment of ${found.projectId}, so no token is minted for it.`, 0);
215
222
  return {
216
223
  id: found.projectId,
217
224
  team: found.ownerId.startsWith("team_") ? found.ownerId : null
@@ -230,7 +237,7 @@ async function tokenFromVercelCli(project) {
230
237
  const { stdout } = await promisify(execFile)("vercel", args, { timeout: 3e4 });
231
238
  return JSON.parse(stdout).token;
232
239
  } catch {
233
- throw new BoardError("not_logged_in", "not signed in to Vercel: run `vercel login`", 0);
240
+ throw new BoardError("not_logged_in", "You're not logged in to Vercel.", 0, { hint: "Run vercel login." });
234
241
  }
235
242
  }
236
243
  /** Your Vercel username, display name, and avatar (Vercel serves avatars publicly). */
@@ -252,13 +259,13 @@ async function githubActionsToken(audience) {
252
259
  const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);
253
260
  url.searchParams.set("audience", audience);
254
261
  const response = await fetch(url, { headers: { authorization: `bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` } });
255
- if (!response.ok) throw new BoardError("no_token", `GitHub Actions refused an OIDC token (${response.status}): does the job have \`permissions: id-token: write\`?`, 0);
262
+ if (!response.ok) throw new BoardError("no_token", `GitHub Actions refused an OIDC token (${response.status}).`, 0, { hint: "Give the job `permissions: id-token: write`." });
256
263
  return (await response.json()).value;
257
264
  }
258
265
  /** The board the environment points at: `BOARD_URL`. */
259
266
  function boardUrl() {
260
267
  const url = process.env.BOARD_URL?.replace(/\/+$/, "");
261
- if (!url) throw new BoardError("no_board", "no board URL: set BOARD_URL", 0);
268
+ if (!url) throw new BoardError("no_board", "There's no board URL: BOARD_URL isn't set.", 0, { hint: "Set BOARD_URL, or pass the URL to http()." });
262
269
  return url;
263
270
  }
264
271
  /**
@@ -272,9 +279,9 @@ function connect(url, options = {}) {
272
279
  const doFetch = guarded(url, options.fetch ?? ((input, init) => globalThis.fetch(input, init)), options.token);
273
280
  let discovery;
274
281
  const discover = () => discovery ??= doFetch(url).then(async (response) => {
275
- if (!response.ok) throw new BoardError("no_board", `no board at ${url} (${response.status})`, response.status);
282
+ if (!response.ok) throw new BoardError("no_board", `There's no board at ${url}: it answered ${response.status}.`, response.status);
276
283
  const found = await response.json();
277
- if (found.audience !== url) throw new BoardError("wrong_audience", `the board at ${url} says its URL is ${found.audience}: use that URL`, 0);
284
+ if (found.audience !== url) throw new BoardError("wrong_audience", `The board at ${url} says its URL is ${shown(found.audience, URL_SHAPE) ?? "another one"}.`, 0, { hint: shown(found.audience, URL_SHAPE) ? `Use ${found.audience}.` : "Use the URL its discovery names." });
278
285
  return found;
279
286
  });
280
287
  let cached = null;
@@ -381,6 +388,19 @@ var Wall = class extends Error {
381
388
  }
382
389
  };
383
390
  /**
391
+ * A wall reached after a redirect to another origin, which none of a request's credentials follow,
392
+ * by design: no bypass or token gets through it from here, so none is looked for.
393
+ */
394
+ var Moved = class extends Error {
395
+ origin;
396
+ status;
397
+ constructor(origin, status) {
398
+ super("moved");
399
+ this.origin = origin;
400
+ this.status = status;
401
+ }
402
+ };
403
+ /**
384
404
  * This deployment's Vercel OIDC token, with `url` as its audience: what a hosted client sends a
385
405
  * board, for Deployment Protection's Trusted Sources. Its `cause` says why it couldn't.
386
406
  */
@@ -388,7 +408,7 @@ async function hostedToken(url) {
388
408
  try {
389
409
  return await getVercelOidcToken({ audience: url });
390
410
  } catch (cause) {
391
- throw Object.assign(new BoardError("no_token", `cannot obtain Vercel OIDC credentials for ${url}: enable OIDC on the calling project or supply token / BOARD_TOKEN; configure Deployment Protection Trusted Sources for its project and environment`, 0), { cause });
411
+ throw Object.assign(new BoardError("no_token", `This deployment can't get a Vercel OIDC token for ${url}.`, 0, { hint: "Enable OIDC on its project, or pass a token (BOARD_TOKEN), and add its project to the board project's Deployment Protection Trusted Sources." }), { cause });
392
412
  }
393
413
  }
394
414
  /** A host this deployment answers on, as Vercel's system variables name them (bare hosts). */
@@ -403,7 +423,12 @@ function guarded(url, doFetch, suppliedToken) {
403
423
  let secret;
404
424
  let looked;
405
425
  let envRejected = false;
406
- return async (input, init = {}) => {
426
+ return (input, init) => attempt(input, init).catch((error) => {
427
+ if (!(error instanceof Moved)) throw error;
428
+ const there = shown(`${error.origin}${new URL(url).pathname}`, URL_SHAPE);
429
+ throw new BoardError("redirected", `${url} redirects to ${there ? new URL(there).origin : "another origin"}, and its credentials don't follow it there, so Deployment Protection turns it away.`, error.status, { hint: there ? `Use ${there}.` : "Use the URL it redirects to." });
430
+ });
431
+ async function attempt(input, init = {}) {
407
432
  const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : void 0));
408
433
  init = {
409
434
  ...init,
@@ -414,7 +439,7 @@ function guarded(url, doFetch, suppliedToken) {
414
439
  return await through(doFetch, input, init, fromEnv);
415
440
  } catch (error) {
416
441
  if (!(error instanceof Wall)) throw error;
417
- if (process.env.VERCEL !== "1") throw new BoardError("protected", `${url} is behind Vercel Deployment Protection and rejected VERCEL_AUTOMATION_BYPASS_SECRET; check it against ${WHERE}`, error.status);
442
+ if (process.env.VERCEL !== "1") throw new BoardError("protected", `${url} is behind Vercel Deployment Protection, and it rejected VERCEL_AUTOMATION_BYPASS_SECRET.`, error.status, { hint: `Check VERCEL_AUTOMATION_BYPASS_SECRET against ${WHERE}.` });
418
443
  envRejected = true;
419
444
  }
420
445
  if (process.env.VERCEL === "1") {
@@ -423,7 +448,7 @@ function guarded(url, doFetch, suppliedToken) {
423
448
  headers.set("x-vercel-trusted-oidc-idp-token", token);
424
449
  }
425
450
  return through(doFetch, input, init, null).catch((error) => {
426
- throw error instanceof Wall ? new BoardError("protected", `${url} rejected Vercel OIDC access: configure Deployment Protection Trusted Sources for the calling project and environment`, error.status) : error;
451
+ throw error instanceof Wall ? new BoardError("protected", `${url} is behind Vercel Deployment Protection, and it rejected this deployment's Vercel OIDC token.`, error.status, { hint: "Add this project and environment to the board project's Deployment Protection Trusted Sources." }) : error;
427
452
  });
428
453
  }
429
454
  const used = await (secret ??= readBypass(host));
@@ -445,9 +470,9 @@ function guarded(url, doFetch, suppliedToken) {
445
470
  return through(doFetch, input, init, found);
446
471
  });
447
472
  } catch (error) {
448
- throw error instanceof Wall ? new BoardError("protected", `${url} is behind Vercel Deployment Protection and its project's automation bypass didn't get through; set VERCEL_AUTOMATION_BYPASS_SECRET (${WHERE})`, error.status) : error;
473
+ throw error instanceof Wall ? new BoardError("protected", `${url} is behind Vercel Deployment Protection, and its project's automation bypass didn't get through.`, error.status, { hint: `Set VERCEL_AUTOMATION_BYPASS_SECRET (${WHERE}).` }) : error;
449
474
  }
450
- };
475
+ }
451
476
  }
452
477
  /**
453
478
  * One request that recognizes the wall: a redirect to `/sso-api`, an HTML
@@ -464,6 +489,8 @@ async function through(doFetch, input, init, bypass) {
464
489
  const method = (init.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
465
490
  let headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : void 0));
466
491
  if (bypass) headers.set("x-vercel-protection-bypass", bypass);
492
+ let moved = null;
493
+ const wall = (status) => moved ? new Moved(moved, status) : new Wall(status);
467
494
  for (let hops = 0;; hops++) {
468
495
  const response = await doFetch(hops === 0 ? input : target, {
469
496
  ...init,
@@ -473,10 +500,11 @@ async function through(doFetch, input, init, bypass) {
473
500
  const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
474
501
  if (location) {
475
502
  const next = new URL(location, target);
476
- if (next.pathname.startsWith("/sso-api")) throw new Wall(response.status);
503
+ if (next.pathname.startsWith("/sso-api")) throw wall(response.status);
477
504
  if (method === "GET" || method === "HEAD") {
478
- if (hops >= 20) throw new BoardError("too_many_redirects", `too many redirects from ${target}`, response.status);
505
+ if (hops >= 20) throw new BoardError("too_many_redirects", `${target} redirected more than 20 times.`, response.status);
479
506
  if (next.origin !== new URL(target).origin) {
507
+ moved = next.origin;
480
508
  headers = new Headers(headers);
481
509
  headers.delete("authorization");
482
510
  headers.delete("x-vercel-trusted-oidc-idp-token");
@@ -487,8 +515,8 @@ async function through(doFetch, input, init, bypass) {
487
515
  }
488
516
  }
489
517
  const { status } = response;
490
- if (/^text\/html\b/i.test(response.headers.get("content-type") ?? "") && !response.headers.has("content-disposition") && (status >= 200 && status < 300 || status === 401 || status === 403)) throw new Wall(status);
491
- if (status === 401 && response.headers.has("x-vercel-error")) throw new Wall(status);
518
+ if (/^text\/html\b/i.test(response.headers.get("content-type") ?? "") && !response.headers.has("content-disposition") && (status >= 200 && status < 300 || status === 401 || status === 403)) throw wall(status);
519
+ if (status === 401 && response.headers.has("x-vercel-error")) throw wall(status);
492
520
  return response;
493
521
  }
494
522
  }
@@ -517,7 +545,7 @@ const automationBypass = (bypasses) => Object.entries(bypasses ?? {}).find(([, v
517
545
  * `protectionBypass` has it. When there's none, it's created.
518
546
  */
519
547
  async function projectBypass(url, host, doFetch) {
520
- const blocked = (why) => new BoardError("protected", `${url} is behind Vercel Deployment Protection, and your Vercel login ${why}, or set VERCEL_AUTOMATION_BYPASS_SECRET (${WHERE})`, 0);
548
+ const blocked = (why, fix) => new BoardError("protected", `${url} is behind Vercel Deployment Protection, and your Vercel login ${why}.`, 0, { hint: `${fix}, or set VERCEL_AUTOMATION_BYPASS_SECRET (${WHERE}).` });
521
549
  let api;
522
550
  try {
523
551
  const token = await vercelLogin();
@@ -536,19 +564,19 @@ async function projectBypass(url, host, doFetch) {
536
564
  api = vercelCliApi;
537
565
  }
538
566
  const project = await findProject(api, host);
539
- if (!project) throw blocked("can't see its project: run `vercel login` or ask for access");
567
+ if (!project) throw blocked("can't see its project", "Log in as someone on its project's team (vercel login), ask for access");
540
568
  const scope = project.ownerId.startsWith("team_") ? `?teamId=${project.ownerId}` : "";
541
569
  const details = await api("GET", `/v9/projects/${project.projectId}${scope}`);
542
- if (!details) throw blocked("can't read its project's settings: ask for access");
570
+ if (!details) throw blocked("can't read its project's settings", "Ask for access to its project");
543
571
  const existing = automationBypass(details.protectionBypass);
544
572
  if (existing) return {
545
573
  secret: existing,
546
574
  created: false
547
575
  };
548
- if (!details.ssoProtection && !details.passwordProtection && !details.passport) throw new BoardError("no_board", `no board at ${url}: it answered with a web page, and its Vercel project isn't protected`, 0);
576
+ if (!details.ssoProtection && !details.passwordProtection && !details.passport) throw new BoardError("no_board", `There's no board at ${url}: it answered with a web page, and its Vercel project isn't protected.`, 0);
549
577
  const created = await api("PATCH", `/v1/projects/${project.projectId}/protection-bypass${scope}`);
550
578
  const secret = automationBypass(created?.protectionBypass);
551
- if (!secret) throw blocked("can't read or create its project's automation bypass: ask a project admin");
579
+ if (!secret) throw blocked("can't read or create its project's automation bypass", "Ask a project admin to create one");
552
580
  process.stderr.write(`created a Protection Bypass for Automation on the Vercel project ${details?.name ?? project.projectId}\n`);
553
581
  return {
554
582
  secret,
@@ -579,7 +607,7 @@ async function findProject(api, host) {
579
607
  let vercelCli;
580
608
  const vercelCliApi = async (method, path) => {
581
609
  vercelCli ??= promisify(execFile)("vercel", ["whoami", "--non-interactive"], { timeout: 1e4 }).then(() => true, () => false);
582
- if (!await vercelCli) throw new BoardError("not_logged_in", "not signed in to Vercel: run `vercel login`", 0);
610
+ if (!await vercelCli) throw new BoardError("not_logged_in", "You're not logged in to Vercel.", 0, { hint: "Run vercel login." });
583
611
  const args = [
584
612
  "api",
585
613
  path,
@@ -595,7 +623,7 @@ const vercelCliApi = async (method, path) => {
595
623
  run.child.stdin?.end(method === "PATCH" ? "{}" : "");
596
624
  return JSON.parse((await run).stdout);
597
625
  } catch (error) {
598
- if (error.code === "ENOENT") throw new BoardError("not_logged_in", "not signed in to Vercel: run `vercel login`", 0);
626
+ if (error.code === "ENOENT") throw new BoardError("not_logged_in", "You're not logged in to Vercel.", 0, { hint: "Run vercel login." });
599
627
  return null;
600
628
  }
601
629
  };