@vercel/board 0.19.0 → 0.20.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.
- package/dist/{a2-C1obi0Qb.mjs → a2-QtmN9EhW.mjs} +36 -6
- package/dist/access.mjs +3 -3
- package/dist/{actors-BOamkEuz.mjs → actors-rPnEQLZu.mjs} +2 -2
- package/dist/{auth-Drg9FiC2.mjs → auth-5nLsx9qB.mjs} +1 -1
- package/dist/{backend-B5GCP1oK.d.mts → backend-CWmd4UH-.d.mts} +8 -1
- package/dist/backend-a2.d.mts +22 -4
- package/dist/backend-a2.mjs +1 -1
- package/dist/{backend-http-Ca6b0FPA.d.mts → backend-http-BfWQfvqB.d.mts} +22 -4
- package/dist/{backend-http-BMVDbtD_.mjs → backend-http-CbcjkHEn.mjs} +7 -3
- package/dist/{backend-http-DMpQ750P.mjs → backend-http-maHy-JW3.mjs} +19 -6
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{chat-provider-B5uCc0an.d.mts → chat-provider-D0k3y8zY.d.mts} +4 -1
- package/dist/{chat-provider-YHy-WbrB.mjs → chat-provider-DnHibqnM.mjs} +4 -1
- package/dist/cli-embedded.d.mts +47 -0
- package/dist/cli-embedded.mjs +64 -0
- package/dist/cli.mjs +73 -1661
- package/dist/{client-BA-J7usH.mjs → client-ClznIBMg.mjs} +2 -0
- package/dist/{conformance-D0Z4dXC_.mjs → conformance-DQ9qVzWP.mjs} +1 -1
- package/dist/conformance.d.mts +1 -1
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-D0oBUBEK.mjs → engine-kHfSOXnQ.mjs} +15 -4
- package/dist/{errors-DX3hpwWt.mjs → errors-CQ-fwxM3.mjs} +13 -1
- package/dist/{exports-CUGshfRF.mjs → exports-Dd8YdgO-.mjs} +15 -6
- package/dist/exports-ggfE9jNv.d.mts +4 -0
- package/dist/index-DsMPCv_K.d.mts +11 -0
- package/dist/index.d.mts +4 -4
- package/dist/{index.default-Cm6GsLdj.mjs → index.default-BqstUHvz.mjs} +1 -1
- package/dist/index.mjs +6 -6
- package/dist/index.node.d.mts +4 -12
- package/dist/index.node.mjs +4 -4
- package/dist/inspect/app.js +93 -92
- package/dist/inspect/engine.js +4 -4
- package/dist/main-CRLa7i3I.mjs +1745 -0
- package/dist/{middleware-CqaCwucP.mjs → middleware-DUjyQP_a.mjs} +1 -1
- package/dist/{provider-B1Z3Ok86.d.mts → provider-D11Dfda0.d.mts} +86 -32
- package/dist/{provider-B3DpYSDq.mjs → provider-poIkXapd.mjs} +4 -0
- package/dist/{serve-DB9ijyQ7.mjs → serve-1kBFSHe_.mjs} +4 -4
- package/dist/{server-BujqWBP5.mjs → server-CuqhG9E6.mjs} +4 -4
- package/dist/{serving-CNkgUPW1.mjs → serving-B307gppu.mjs} +1 -1
- package/dist/sync-github.d.mts +2 -2
- package/dist/sync-github.mjs +2 -2
- package/dist/sync-kapso.d.mts +2 -2
- package/dist/sync-kapso.mjs +2 -2
- package/dist/sync-linear.d.mts +2 -2
- package/dist/sync-linear.mjs +2 -2
- package/dist/sync-slack.d.mts +11 -2
- package/dist/sync-slack.mjs +57 -17
- package/dist/sync.chat.d.mts +1 -1
- package/dist/sync.chat.mjs +1 -1
- package/dist/sync.d.mts +1 -1
- package/dist/sync.mjs +1 -1
- package/dist/{tokens-DhuzKzAl.mjs → tokens-DUXaXwOj.mjs} +2 -2
- package/dist/{watchers-hash-B2TevhFZ.mjs → watchers-hash-Cd6ZLo9m.mjs} +38 -25
- package/docs/02-sync/03-slack.md +14 -1
- package/docs/02-sync/06-providers.md +1 -0
- package/docs/04-reference/01-cli.md +53 -2
- package/docs/04-reference/02-board.md +8 -3
- package/docs/04-reference/03-http.md +4 -3
- package/package.json +5 -1
- package/dist/exports-B3AFzT_f.d.mts +0 -4
package/dist/cli.mjs
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { t as conformance } from "./conformance-D0Z4dXC_.mjs";
|
|
10
|
-
import { t as displayText } from "./bots-CGkO76qH.mjs";
|
|
11
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
12
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
13
|
-
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
14
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { homedir, tmpdir } from "node:os";
|
|
16
|
-
import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
|
|
17
|
-
import { parseArgs } from "node:util";
|
|
2
|
+
import { t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
|
|
3
|
+
import "./schemas-Be08nyfW.mjs";
|
|
4
|
+
import { c as projectUrl, s as projectDir } from "./backend-http-maHy-JW3.mjs";
|
|
5
|
+
import { i as who, n as main } from "./main-CRLa7i3I.mjs";
|
|
6
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
18
9
|
import { createServer } from "node:http";
|
|
19
10
|
//#region src/inspect/style.ts
|
|
20
11
|
/**
|
|
@@ -602,1662 +593,83 @@ function listenOn(server, port) {
|
|
|
602
593
|
});
|
|
603
594
|
}
|
|
604
595
|
//#endregion
|
|
605
|
-
//#region src/cli/
|
|
606
|
-
/**
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
*/
|
|
613
|
-
const terminalText = (text) => text.replace(/\r\n/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (char) => {
|
|
614
|
-
const code = char.charCodeAt(0);
|
|
615
|
-
return code < 32 ? String.fromCharCode(9216 + code) : code === 127 ? "␡" : "�";
|
|
616
|
-
});
|
|
617
|
-
const who = (me) => me.profile !== null && me.handle.includes("/") ? `${me.name} via ${me.handle.slice(0, me.handle.indexOf("/"))}` : me.name;
|
|
618
|
-
function authorName(message) {
|
|
619
|
-
const author = message.author;
|
|
620
|
-
if (!author) return message.by.actor;
|
|
621
|
-
const slash = author.handle.indexOf("/");
|
|
622
|
-
if (message.by.profile !== null && slash !== -1) return `${author.name} via ${author.handle.slice(0, slash)}`;
|
|
623
|
-
if (author.person && author.person.handle !== author.handle) return `${author.name} (${author.person.handle})`;
|
|
624
|
-
return author.name;
|
|
625
|
-
}
|
|
626
|
-
function ago(iso, now = Date.now()) {
|
|
627
|
-
const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
|
|
628
|
-
if (seconds < 60) return `${seconds}s`;
|
|
629
|
-
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
630
|
-
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
631
|
-
return `${Math.floor(seconds / 86400)}d`;
|
|
632
|
-
}
|
|
633
|
-
const indent = (text, prefix) => text.split("\n").map((line) => `${prefix}${line}`).join("\n");
|
|
634
|
-
function labels(message) {
|
|
635
|
-
const entries = Object.entries(message.labels);
|
|
636
|
-
return entries.length ? ` [${entries.map(([k, v]) => `${k}=${v}`).join(" ")}]` : "";
|
|
637
|
-
}
|
|
638
|
-
function reactions(message, names) {
|
|
639
|
-
const groups = Object.entries(message.reactions);
|
|
640
|
-
if (groups.length === 0) return "";
|
|
641
|
-
return groups.map(([emoji, actors]) => {
|
|
642
|
-
const shown = actors.slice(0, 3).map((id) => names.get(id) ?? id).join(", ");
|
|
643
|
-
return `${displayEmoji(emoji)} ${shown}${actors.length > 3 ? ` +${actors.length - 3}` : ""}`;
|
|
644
|
-
}).join(" ");
|
|
645
|
-
}
|
|
646
|
-
/** The actors a thread's reactions name on screen, for looking up the ones who didn't write in it. */
|
|
647
|
-
function reactionNames(messages) {
|
|
648
|
-
return [...new Set(messages.flatMap((m) => Object.values(m.reactions).flatMap((actors) => actors.slice(0, 3))))];
|
|
649
|
-
}
|
|
650
|
-
/** An actor's display name from `/actors`, the way a message's author shows: `claude via julian`. */
|
|
651
|
-
function actorName(actor) {
|
|
652
|
-
const slash = actor.handle.indexOf("/");
|
|
653
|
-
return slash === -1 ? actor.name : `${actor.name} via ${actor.handle.slice(0, slash)}`;
|
|
654
|
-
}
|
|
655
|
-
/** Lines of a linked message an embed shows before it trails off. */
|
|
656
|
-
const EMBED_LINES = 12;
|
|
657
|
-
/** A linked platform message, quoted under the message that links it, or why the board couldn't read it. */
|
|
658
|
-
function embed(link, pad) {
|
|
659
|
-
if (!link.snapshot) return [`${pad}↪ ${link.url} (couldn't read it: ${link.unavailable ?? "unknown"})`];
|
|
660
|
-
const { author, text, files } = link.snapshot;
|
|
661
|
-
const lines = text.trim() ? text.trim().split("\n") : [];
|
|
662
|
-
const shown = lines.length > EMBED_LINES ? [...lines.slice(0, EMBED_LINES), "…"] : lines;
|
|
663
|
-
return [
|
|
664
|
-
`${pad}↪ ${author}${link.message !== null ? ` · #${link.message}` : ""} · ${link.url}`,
|
|
665
|
-
...shown.map((line) => `${pad}> ${line}`),
|
|
666
|
-
...files.map((file) => `${pad}> file ${file.name}${file.url ? ` ${file.url}` : ""}`)
|
|
667
|
-
];
|
|
668
|
-
}
|
|
669
|
-
/** One message as a block: header line, indented text, reactions. */
|
|
670
|
-
function formatMessage(message, options = {}) {
|
|
671
|
-
const pad = options.reply ? " " : " ";
|
|
672
|
-
const head = `${options.reply ? " ↳ " : ""}#${message.id} ${authorName(message)} · ${ago(message.createdAt)}${message.editedAt ? " (edited)" : ""}${message.schedule ? ` · scheduled s${message.schedule.id}` : ""}${labels(message)}`;
|
|
673
|
-
const body = message.deleted ? `${pad}(message removed)` : indent(displayText(message.text, message.by.actor), pad);
|
|
674
|
-
const files = (message.attachments ?? []).map((a) => `${pad}file ${a.name} · ${a.type} · ${fileSize(a.size)} · ${a.id}`);
|
|
675
|
-
const links = message.deleted ? [] : (message.links ?? []).flatMap((link) => embed(link, pad));
|
|
676
|
-
const reacted = reactions(message, options.names ?? /* @__PURE__ */ new Map());
|
|
677
|
-
const undelivered = Object.entries(message.undelivered ?? {}).map(([target, error]) => `${pad}✗ didn't reach ${target}: ${error}`);
|
|
678
|
-
return [
|
|
679
|
-
head,
|
|
680
|
-
body,
|
|
681
|
-
...files,
|
|
682
|
-
...links,
|
|
683
|
-
...reacted ? [`${pad}${reacted}`] : [],
|
|
684
|
-
...undelivered
|
|
685
|
-
].join("\n");
|
|
686
|
-
}
|
|
687
|
-
/** Threads in order, each root followed by its replies. */
|
|
688
|
-
function formatThreads(messages, known = /* @__PURE__ */ new Map()) {
|
|
689
|
-
const names = new Map([...known, ...messages.map((m) => [m.by.actor, authorName(m)])]);
|
|
690
|
-
const byRoot = /* @__PURE__ */ new Map();
|
|
691
|
-
const roots = [];
|
|
692
|
-
const orphans = [];
|
|
693
|
-
for (const message of messages) if (message.re === null) roots.push(message);
|
|
694
|
-
else (byRoot.get(message.re) ?? byRoot.set(message.re, []).get(message.re)).push(message);
|
|
695
|
-
const rootIds = new Set(roots.map((r) => r.id));
|
|
696
|
-
for (const [root, replies] of byRoot) if (!rootIds.has(root)) orphans.push(...replies);
|
|
697
|
-
const blocks = roots.map((root) => [formatMessage(root, { names }), ...(byRoot.get(root.id) ?? []).map((r) => formatMessage(r, {
|
|
698
|
-
reply: true,
|
|
699
|
-
names
|
|
700
|
-
}))].join("\n"));
|
|
701
|
-
for (const reply of orphans) blocks.push(`(in thread #${reply.re})\n${formatMessage(reply, {
|
|
702
|
-
reply: true,
|
|
703
|
-
names
|
|
704
|
-
})}`);
|
|
705
|
-
return blocks.join("\n\n");
|
|
706
|
-
}
|
|
707
|
-
/** A root's first line, cut to fit 80 columns under its header. */
|
|
708
|
-
function firstLine(message) {
|
|
709
|
-
if (message.deleted) return "(message removed)";
|
|
710
|
-
const text = displayText(message.text, message.by.actor).trim();
|
|
711
|
-
const files = message.attachments?.length ?? 0;
|
|
712
|
-
if (!text) return files ? `(${files} ${files === 1 ? "file" : "files"})` : "(no text)";
|
|
713
|
-
const [line = "", ...rest] = text.split(/\r?\n/);
|
|
714
|
-
const shown = rest.length ? `${line.trimEnd()} …` : line;
|
|
715
|
-
return shown.length > 74 ? `${line.slice(0, 73)}…` : shown;
|
|
716
|
-
}
|
|
717
|
-
/**
|
|
718
|
-
* One block per thread, for `read <channel>`: the root's header and first line,
|
|
719
|
-
* then how many replies it has and who wrote the latest, and when.
|
|
720
|
-
*/
|
|
721
|
-
function formatThreadSummaries(threads, options = {}) {
|
|
722
|
-
const now = options.now ?? Date.now();
|
|
723
|
-
return threads.map(({ message, replies, last }) => {
|
|
724
|
-
const where = options.channel ? `${message.channel} ` : "";
|
|
725
|
-
const head = `#${message.id} ${where}${authorName(message)} · ${ago(message.createdAt, now)}${labels(message)}`;
|
|
726
|
-
const activity = last ? `${replies} ${replies === 1 ? "reply" : "replies"} · last #${last.id} ${authorName(last)} · ${ago(last.createdAt, now)}` : "no replies";
|
|
727
|
-
return [
|
|
728
|
-
head,
|
|
729
|
-
` ${firstLine(message)}`,
|
|
730
|
-
` ${activity}`
|
|
731
|
-
].join("\n");
|
|
732
|
-
}).join("\n\n");
|
|
733
|
-
}
|
|
734
|
-
/**
|
|
735
|
-
* `/me`'s `can` as `whoami` rows, a pattern and its permissions each (`ci/ read
|
|
736
|
-
* post`), or none for full access. `manage` brings everything, so it says `all`.
|
|
737
|
-
*/
|
|
738
|
-
function formatAccess(access) {
|
|
739
|
-
const patterns = Object.keys(access);
|
|
740
|
-
if (patterns.length === 1 && patterns[0] === "*" && access["*"].includes("manage")) return [];
|
|
741
|
-
if (patterns.length === 0) return ["nothing"];
|
|
742
|
-
const width = Math.max(...patterns.map((pattern) => pattern.length));
|
|
743
|
-
return patterns.map((pattern) => {
|
|
744
|
-
const permissions = access[pattern];
|
|
745
|
-
return `${pattern.padEnd(width)} ${permissions.includes("manage") ? "all" : permissions.length === 0 ? "nothing" : permissions.join(" ")}`;
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
/** `90m` → `1h30m`, `1d`: milliseconds as a duration flag would say them. */
|
|
749
|
-
function span(ms) {
|
|
750
|
-
const parts = [];
|
|
751
|
-
let rest = Math.round(ms / 1e3);
|
|
752
|
-
for (const [unit, size] of [
|
|
753
|
-
["d", 86400],
|
|
754
|
-
["h", 3600],
|
|
755
|
-
["m", 60],
|
|
756
|
-
["s", 1]
|
|
757
|
-
]) {
|
|
758
|
-
if (rest >= size) parts.push(`${Math.floor(rest / size)}${unit}`);
|
|
759
|
-
rest %= size;
|
|
760
|
-
}
|
|
761
|
-
return parts.join("") || "0s";
|
|
762
|
-
}
|
|
763
|
-
/** `in 25m`, or `due` once it's past. */
|
|
764
|
-
const until = (iso, now) => {
|
|
765
|
-
const ms = Date.parse(iso) - now;
|
|
766
|
-
if (ms <= 0) return "due";
|
|
767
|
-
return `in ${span(ms < 6e4 ? Math.ceil(ms / 1e3) * 1e3 : Math.ceil(ms / 6e4) * 6e4)}`;
|
|
768
|
-
};
|
|
769
|
-
/** One schedule: what it sends, when, where, and how it stops. */
|
|
770
|
-
function formatSchedule(schedule, options = {}) {
|
|
771
|
-
const now = options.now ?? Date.now();
|
|
772
|
-
const pad = options.pad ?? "";
|
|
773
|
-
const where = schedule.re === null ? `new thread in ${schedule.channel}` : `#${schedule.re}`;
|
|
774
|
-
const repeat = schedule.every === null ? "once" : `every ${span(schedule.every)}`;
|
|
775
|
-
const stop = schedule.every === null ? "" : schedule.times !== null ? ` · ${schedule.times} times` : schedule.until !== null ? ` · until ${schedule.until}` : " · until canceled";
|
|
776
|
-
const when = schedule.state === "pending" ? `next ${until(schedule.next, now)} (${schedule.next})` : schedule.state === "canceled" ? schedule.canceled?.reason === "thread_deleted" ? "ended: the thread was deleted" : schedule.canceled?.reason === "dropped" ? "ended: the board dropped it" : schedule.canceled?.reason === "revoked" ? "ended: its creator can no longer post it" : "canceled" : "done";
|
|
777
|
-
const sent = schedule.sent > 0 ? ` · ${schedule.sent} sent` : "";
|
|
778
|
-
const files = schedule.attachments.length ? ` · ${schedule.attachments.length} ${schedule.attachments.length === 1 ? "file" : "files"}` : "";
|
|
779
|
-
const text = displayText(schedule.text, schedule.by.actor);
|
|
780
|
-
return [`${pad}s${schedule.id} ${where} · ${repeat}${stop} · ${when}${sent}${files} · by ${authorName(schedule)}`, indent(text || "(no text)", `${pad} `)].join("\n");
|
|
781
|
-
}
|
|
782
|
-
/** One line per live event, for `tail` and `wait`. */
|
|
783
|
-
function formatEvent(event) {
|
|
784
|
-
const message = event.message;
|
|
785
|
-
if (!message) return `#${event.cursor} ${event.type}`;
|
|
786
|
-
const where = message.re === null ? message.channel : `${message.channel} ↳ #${message.re}`;
|
|
787
|
-
const who = authorName(message);
|
|
788
|
-
switch (event.type) {
|
|
789
|
-
case "message.posted": {
|
|
790
|
-
const files = (message.attachments ?? []).map((a) => ` · file ${a.name}`).join("");
|
|
791
|
-
return `#${message.id} ${where} ${who}: ${displayText(message.text, message.by.actor)}${files}`;
|
|
792
|
-
}
|
|
793
|
-
case "message.edited": return `#${message.id} ${where} ${who} (edited): ${displayText(message.text, message.by.actor)}`;
|
|
794
|
-
case "message.deleted": return `#${message.id} ${where} (message removed)`;
|
|
795
|
-
case "message.linked": return `#${message.id} ${where} ↪ links ${(message.links ?? []).map((link) => link.message !== null ? `#${link.message}` : link.url).join(", ") || "nothing"}`;
|
|
796
|
-
case "reaction.added":
|
|
797
|
-
case "reaction.removed": return `#${message.id} ${where} ${displayEmoji(event.payload.emoji)} ${event.type === "reaction.added" ? "added" : "removed"} by ${event.actor?.name ?? event.payload.by.actor}`;
|
|
798
|
-
case "labels.changed": {
|
|
799
|
-
const set = Object.entries(event.payload.set).map(([k, v]) => `${k}=${v}`);
|
|
800
|
-
const unset = event.payload.unset.map((k) => `-${k}`);
|
|
801
|
-
return `#${message.id} ${where} labels ${[...set, ...unset].join(" ")}${event.actor ? ` by ${event.actor.name}` : ""}`;
|
|
802
|
-
}
|
|
803
|
-
case "message.sync_failed": return `#${message.id} ${where} ✗ didn't reach ${event.payload.target}: ${event.payload.error}`;
|
|
804
|
-
default: return `#${event.cursor} ${event.type}`;
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
/**
|
|
808
|
-
* Where a thread is synced, for `vb sync <id>`: a row a target, its sync key and where it is, then
|
|
809
|
-
* the link to the message's copy there, or why it didn't reach it.
|
|
810
|
-
*/
|
|
811
|
-
function formatSynced(targets) {
|
|
812
|
-
const where = (row) => row.target.slice(row.key.length + 1);
|
|
813
|
-
const keys = Math.max(...targets.map((row) => row.key.length));
|
|
814
|
-
const wheres = Math.max(...targets.map((row) => where(row).length));
|
|
815
|
-
return targets.map((row) => {
|
|
816
|
-
const copy = row.undelivered !== null ? `didn't reach it: ${row.undelivered}` : row.url ?? (row.message !== null ? `message ${row.message}` : "no copy yet");
|
|
817
|
-
return `${row.key.padEnd(keys)} ${where(row).padEnd(wheres)} ${terminalText(copy)}`;
|
|
818
|
-
}).join("\n");
|
|
819
|
-
}
|
|
820
|
-
/**
|
|
821
|
-
* A channel or thread read with `vb sources <key> read`: a header, then each message under its ID,
|
|
822
|
-
* with the target that reads its thread when it has replies.
|
|
823
|
-
*/
|
|
824
|
-
function formatSourceRead(key, read, now = Date.now()) {
|
|
825
|
-
const where = `${key}:${read.channel}${read.name ? ` (#${read.name})` : ""}${read.thread ? `, thread ${read.thread}` : ""}`;
|
|
826
|
-
if (read.messages.length === 0) return `${where}\nno messages`;
|
|
827
|
-
const blocks = read.messages.map((message) => {
|
|
828
|
-
const reply = read.thread !== null && message.id !== read.thread;
|
|
829
|
-
const pad = reply ? " " : " ";
|
|
830
|
-
const when = message.createdAt ? ` · ${ago(message.createdAt, now)}` : "";
|
|
831
|
-
const replies = message.replies > 0 && read.thread === null ? ` (${message.replies} ${message.replies === 1 ? "reply" : "replies"}: vb sources ${key} read ${message.thread})` : "";
|
|
832
|
-
return [
|
|
833
|
-
`${reply ? " ↳ " : ""}${message.id} ${message.author}${when}${replies}`,
|
|
834
|
-
indent(message.text || "(no text)", pad),
|
|
835
|
-
...message.files.map((file) => `${pad}file ${file.name}${file.url ? ` ${file.url}` : ""}`)
|
|
836
|
-
].join("\n");
|
|
837
|
-
});
|
|
838
|
-
const more = read.more ? [`(${read.thread ? "later replies" : "older messages"} not shown: raise --limit, up to 200)`] : [];
|
|
839
|
-
return [
|
|
840
|
-
where,
|
|
841
|
-
...blocks,
|
|
842
|
-
...more
|
|
843
|
-
].join("\n");
|
|
844
|
-
}
|
|
845
|
-
/**
|
|
846
|
-
* A conformance report: each group a heading, its checks under it, and why for one that failed
|
|
847
|
-
* or was skipped, then the counts.
|
|
848
|
-
*/
|
|
849
|
-
function formatConformance(report) {
|
|
850
|
-
const blocks = [];
|
|
851
|
-
for (const group of [...new Set(report.checks.map((check) => check.group))]) {
|
|
852
|
-
const lines = [group];
|
|
853
|
-
for (const check of report.checks.filter((c) => c.group === group)) {
|
|
854
|
-
lines.push(` ${check.status.padEnd(7)} ${check.name}`);
|
|
855
|
-
if (check.detail) lines.push(` ${check.detail}`);
|
|
856
|
-
}
|
|
857
|
-
blocks.push(lines.join("\n"));
|
|
858
|
-
}
|
|
859
|
-
const count = (status) => report.checks.filter((check) => check.status === status).length;
|
|
860
|
-
const counts = [
|
|
861
|
-
`${count("passed")} passed`,
|
|
862
|
-
...count("skipped") ? [`${count("skipped")} skipped`] : [],
|
|
863
|
-
...count("failed") ? [`${count("failed")} failed`] : []
|
|
864
|
-
];
|
|
865
|
-
return `${blocks.join("\n\n")}\n\n${counts.join(", ")}`;
|
|
866
|
-
}
|
|
867
|
-
//#endregion
|
|
868
|
-
//#region src/cli/hooks.ts
|
|
869
|
-
/**
|
|
870
|
-
* Where each agent keeps hooks, and which of its events run ours. Claude Code
|
|
871
|
-
* and Codex read `{ hooks: { Stop: [{ hooks: [entry] }] } }`; Cursor reads
|
|
872
|
-
* `{ version: 1, hooks: { stop: [entry] } }`, and its stop can't block, so it's
|
|
873
|
-
* reminded with a follow-up message instead. Pi has no hook files: its hook is
|
|
874
|
-
* an extension we write whole (`piExtension`), in `$PI_CODING_AGENT_DIR/extensions`.
|
|
875
|
-
*/
|
|
876
|
-
const AGENTS = {
|
|
877
|
-
claude: {
|
|
878
|
-
name: "Claude Code",
|
|
879
|
-
shape: "nested",
|
|
880
|
-
event: "Stop",
|
|
881
|
-
file: () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "settings.json")
|
|
882
|
-
},
|
|
883
|
-
codex: {
|
|
884
|
-
name: "Codex",
|
|
885
|
-
shape: "nested",
|
|
886
|
-
event: "Stop",
|
|
887
|
-
file: () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "hooks.json")
|
|
888
|
-
},
|
|
889
|
-
cursor: {
|
|
890
|
-
name: "Cursor",
|
|
891
|
-
shape: "flat",
|
|
892
|
-
event: "stop",
|
|
893
|
-
file: () => join(homedir(), ".cursor", "hooks.json")
|
|
894
|
-
},
|
|
895
|
-
pi: {
|
|
896
|
-
name: "Pi",
|
|
897
|
-
shape: "extension",
|
|
898
|
-
event: "agent_before_settle",
|
|
899
|
-
file: () => join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "extensions", "board.ts")
|
|
900
|
-
}
|
|
901
|
-
};
|
|
902
|
-
const ours = (entry) => / hook stop --agent /.test(entry.command ?? "");
|
|
903
|
-
function readConfig(path) {
|
|
904
|
-
try {
|
|
905
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
906
|
-
} catch (error) {
|
|
907
|
-
if (error.code === "ENOENT") return {};
|
|
908
|
-
throw new Error(`can't read ${path}: ${error.message}`);
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
/** Written to a sibling, then renamed: a crash never leaves an agent's settings half-written. */
|
|
912
|
-
function writeConfig(path, config) {
|
|
913
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
914
|
-
writeFileSync(`${path}.board-tmp`, `${JSON.stringify(config, null, 2)}\n`);
|
|
915
|
-
renameSync(`${path}.board-tmp`, path);
|
|
916
|
-
}
|
|
917
|
-
/** An event's entries without ours; a group left with no hooks goes too. */
|
|
918
|
-
function without(entries, shape) {
|
|
919
|
-
if (shape === "flat") return entries.filter((entry) => !ours(entry));
|
|
920
|
-
return entries.map((group) => ({
|
|
921
|
-
...group,
|
|
922
|
-
hooks: (group.hooks ?? []).filter((h) => !ours(h))
|
|
923
|
-
})).filter((group) => group.hooks.length > 0);
|
|
924
|
-
}
|
|
925
|
-
/** The line of our Pi extension that holds the command it runs: how status finds it. */
|
|
926
|
-
const PI_COMMAND = /^const COMMAND = (".*")$/m;
|
|
927
|
-
/**
|
|
928
|
-
* Pi's turn-end hook, as an extension. Before Pi settles a completed run, it
|
|
929
|
-
* runs our command with what Claude Code's hook gets (the session, the
|
|
930
|
-
* directory, and whether this hook made it continue); a reminder comes back
|
|
931
|
-
* as a message, and Pi continues once to act on it. Pi's bash tool exports the
|
|
932
|
-
* same session as `PI_SESSION_ID`, so the agent's `vb reply` or `vb post` counts for it.
|
|
933
|
-
*/
|
|
934
|
-
function piExtension(command) {
|
|
935
|
-
return `// Added by \`vb hooks install pi\`: reminds Pi, once per turn, to update the team's board.
|
|
936
|
-
// \`vb hooks uninstall pi\` removes it; reinstalling replaces this file.
|
|
937
|
-
import { spawn } from 'node:child_process'
|
|
938
|
-
|
|
939
|
-
const COMMAND = ${JSON.stringify(command)}
|
|
940
|
-
|
|
941
|
-
function run(cwd: string, input: string): Promise<string> {
|
|
942
|
-
return new Promise((resolve) => {
|
|
943
|
-
let out = ''
|
|
944
|
-
const child = spawn('/bin/sh', ['-c', COMMAND], { cwd, stdio: ['pipe', 'pipe', 'ignore'] })
|
|
945
|
-
const timer = setTimeout(() => child.kill(), 10_000)
|
|
946
|
-
child.stdout.on('data', (chunk) => (out += chunk))
|
|
947
|
-
child.on('error', () => resolve(''))
|
|
948
|
-
child.on('close', () => (clearTimeout(timer), resolve(out)))
|
|
949
|
-
child.stdin.on('error', () => {})
|
|
950
|
-
child.stdin.end(input)
|
|
951
|
-
})
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
export default function board(pi: any) {
|
|
955
|
-
let reminded = false
|
|
956
|
-
pi.on('agent_before_settle', async (event: any, ctx: any) => {
|
|
957
|
-
const continuing = reminded
|
|
958
|
-
reminded = false
|
|
959
|
-
if (event.outcome !== 'completed') return
|
|
960
|
-
try {
|
|
961
|
-
const input = JSON.stringify({ session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, stop_hook_active: continuing })
|
|
962
|
-
const reason = JSON.parse((await run(ctx.cwd, input)) || '{}').reason
|
|
963
|
-
if (typeof reason !== 'string' || !reason) return
|
|
964
|
-
reminded = true
|
|
965
|
-
return { entries: [...event.entries, { type: 'custom_message', customType: 'board', content: reason, display: true }], continue: true }
|
|
966
|
-
} catch {}
|
|
967
|
-
})
|
|
968
|
-
}
|
|
969
|
-
`;
|
|
970
|
-
}
|
|
971
|
-
/**
|
|
972
|
-
* The command our hook runs. `|| true`: whatever `cli` turns out to be (an older board without
|
|
973
|
-
* `hook` exits 2, which Claude Code and Codex read as "keep going"), the hook can't fail a turn.
|
|
974
|
-
*/
|
|
975
|
-
const hookCommand = (agent, cli, flags = "") => `${cli} hook stop --agent ${agent}${flags} 2>/dev/null || true`;
|
|
976
|
-
/** Adds our hook, replacing an earlier one of ours; everything else in the file stays. Answers whether it changed. */
|
|
977
|
-
function installHook(agent, cli, flags = "") {
|
|
978
|
-
const spec = AGENTS[agent];
|
|
979
|
-
const command = hookCommand(agent, cli, flags);
|
|
980
|
-
if (spec.shape === "extension") {
|
|
981
|
-
const source = piExtension(command);
|
|
982
|
-
let current = null;
|
|
983
|
-
try {
|
|
984
|
-
current = readFileSync(spec.file(), "utf8");
|
|
985
|
-
} catch {}
|
|
986
|
-
if (current === source) return false;
|
|
987
|
-
if (current !== null && !current.startsWith("// Added by `vb hooks install pi`")) throw new Error(`${spec.file()} isn't board's: move it, then install again`);
|
|
988
|
-
mkdirSync(dirname(spec.file()), { recursive: true });
|
|
989
|
-
writeFileSync(`${spec.file()}.board-tmp`, source);
|
|
990
|
-
renameSync(`${spec.file()}.board-tmp`, spec.file());
|
|
991
|
-
return true;
|
|
992
|
-
}
|
|
993
|
-
const config = readConfig(spec.file());
|
|
994
|
-
const entry = spec.shape === "flat" ? {
|
|
995
|
-
command,
|
|
996
|
-
timeout: 10
|
|
997
|
-
} : { hooks: [{
|
|
998
|
-
type: "command",
|
|
999
|
-
command,
|
|
1000
|
-
timeout: 10
|
|
1001
|
-
}] };
|
|
1002
|
-
const hooks = {
|
|
1003
|
-
...config.hooks,
|
|
1004
|
-
[spec.event]: [...without(config.hooks?.[spec.event] ?? [], spec.shape), entry]
|
|
1005
|
-
};
|
|
1006
|
-
const next = {
|
|
1007
|
-
...spec.shape === "flat" ? { version: 1 } : {},
|
|
1008
|
-
...config,
|
|
1009
|
-
hooks
|
|
1010
|
-
};
|
|
1011
|
-
if (JSON.stringify(next) === JSON.stringify(config)) return false;
|
|
1012
|
-
writeConfig(spec.file(), next);
|
|
1013
|
-
return true;
|
|
1014
|
-
}
|
|
1015
|
-
/** Removes our hook. A file that held only it (Cursor's, which we created) goes away with it. */
|
|
1016
|
-
function uninstallHook(agent) {
|
|
1017
|
-
const spec = AGENTS[agent];
|
|
1018
|
-
if (installedHook(agent) === null) return false;
|
|
1019
|
-
if (spec.shape === "extension") return unlinkSync(spec.file()), true;
|
|
1020
|
-
const config = readConfig(spec.file());
|
|
1021
|
-
const hooks = { ...config.hooks };
|
|
1022
|
-
const kept = without(hooks[spec.event] ?? [], spec.shape);
|
|
1023
|
-
if (kept.length > 0) hooks[spec.event] = kept;
|
|
1024
|
-
else delete hooks[spec.event];
|
|
1025
|
-
if (Object.keys(config).filter((key) => key !== "hooks" && key !== "version").length === 0 && Object.keys(hooks).length === 0) unlinkSync(spec.file());
|
|
1026
|
-
else writeConfig(spec.file(), {
|
|
1027
|
-
...config,
|
|
1028
|
-
hooks
|
|
1029
|
-
});
|
|
1030
|
-
return true;
|
|
1031
|
-
}
|
|
1032
|
-
/** The command our hook runs for `agent`, if it's installed. */
|
|
1033
|
-
function installedHook(agent) {
|
|
1034
|
-
const spec = AGENTS[agent];
|
|
1035
|
-
if (spec.shape === "extension") try {
|
|
1036
|
-
const line = PI_COMMAND.exec(readFileSync(spec.file(), "utf8"));
|
|
1037
|
-
return line ? JSON.parse(line[1]) : null;
|
|
1038
|
-
} catch {
|
|
1039
|
-
return null;
|
|
1040
|
-
}
|
|
1041
|
-
const entries = readConfig(spec.file()).hooks?.[spec.event] ?? [];
|
|
1042
|
-
return (spec.shape === "flat" ? entries : entries.flatMap((group) => group.hooks ?? [])).find(ours)?.command ?? null;
|
|
1043
|
-
}
|
|
1044
|
-
/** A path under the home directory as `~/…`, the way people write it. */
|
|
1045
|
-
function tilde(path) {
|
|
1046
|
-
const home = homedir();
|
|
1047
|
-
return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
|
|
1048
|
-
}
|
|
1049
|
-
/** The CLI an installed hook runs: everything before ` hook stop`. */
|
|
1050
|
-
const cliOf = (command) => command.slice(0, command.indexOf(" hook stop "));
|
|
1051
|
-
/**
|
|
1052
|
-
* The reminder asks the agent to run `vb reply`, `vb post`, `vb read`, and `vb channels`;
|
|
1053
|
-
* where the agent keeps an allowlist, those run without a permission prompt.
|
|
1054
|
-
* Claude Code matches command prefixes; Cursor matches a command's name, so it
|
|
1055
|
-
* can allow only a `vb` on PATH. Codex has no command allowlist: its sandbox
|
|
1056
|
-
* decides (see `codexNetwork`).
|
|
1057
|
-
*/
|
|
1058
|
-
const REMINDED = [
|
|
1059
|
-
"reply",
|
|
1060
|
-
"post",
|
|
1061
|
-
"read",
|
|
1062
|
-
"channels"
|
|
1063
|
-
];
|
|
1064
|
-
/** Rules an earlier install added that this one doesn't: `vb update`, since removed. Uninstall drops them too. */
|
|
1065
|
-
function legacyRules(agent, cli) {
|
|
1066
|
-
return agent === "claude" ? [`Bash(${cli} update:*)`] : [];
|
|
1067
|
-
}
|
|
1068
|
-
function allowRules(agent, cli) {
|
|
1069
|
-
if (agent === "claude") return {
|
|
1070
|
-
file: AGENTS.claude.file(),
|
|
1071
|
-
rules: REMINDED.map((sub) => `Bash(${cli} ${sub}:*)`)
|
|
1072
|
-
};
|
|
1073
|
-
if (agent === "cursor" && cli === "vb") return {
|
|
1074
|
-
file: join(homedir(), ".cursor", "cli-config.json"),
|
|
1075
|
-
rules: ["Shell(vb)"]
|
|
1076
|
-
};
|
|
1077
|
-
return null;
|
|
1078
|
-
}
|
|
1079
|
-
/** Adds the rules a file doesn't have yet. Answers the ones it added. */
|
|
1080
|
-
function allow(file, rules) {
|
|
1081
|
-
const config = readConfig(file);
|
|
1082
|
-
const allowed = config.permissions?.allow ?? [];
|
|
1083
|
-
const added = rules.filter((rule) => !allowed.includes(rule));
|
|
1084
|
-
if (added.length > 0) writeConfig(file, {
|
|
1085
|
-
...config,
|
|
1086
|
-
permissions: {
|
|
1087
|
-
...config.permissions,
|
|
1088
|
-
allow: [...allowed, ...added]
|
|
1089
|
-
}
|
|
1090
|
-
});
|
|
1091
|
-
return added;
|
|
1092
|
-
}
|
|
1093
|
-
/** Removes the rules a file has. Answers whether it removed any. */
|
|
1094
|
-
function disallow(file, rules) {
|
|
1095
|
-
const config = readConfig(file);
|
|
1096
|
-
const allowed = config.permissions?.allow;
|
|
1097
|
-
if (!allowed?.some((rule) => rules.includes(rule))) return false;
|
|
1098
|
-
writeConfig(file, {
|
|
1099
|
-
...config,
|
|
1100
|
-
permissions: {
|
|
1101
|
-
...config.permissions,
|
|
1102
|
-
allow: allowed.filter((rule) => !rules.includes(rule))
|
|
1103
|
-
}
|
|
1104
|
-
});
|
|
1105
|
-
return true;
|
|
1106
|
-
}
|
|
1107
|
-
/**
|
|
1108
|
-
* Codex's default sandbox has no network, so `vb` can't reach the board. A
|
|
1109
|
-
* permission profile can allow just the board, and Vercel's API for its token.
|
|
1110
|
-
* In TOML a key after a `[table]` belongs to it, so `default_permissions` goes
|
|
1111
|
-
* at the top; the tables go anywhere (`[features]` merged into one that's there).
|
|
1112
|
-
*/
|
|
1113
|
-
function codexNetwork(boardHost) {
|
|
1114
|
-
return {
|
|
1115
|
-
top: "default_permissions = \"board\"",
|
|
1116
|
-
tables: [
|
|
1117
|
-
"[features]",
|
|
1118
|
-
"network_proxy = true",
|
|
1119
|
-
"",
|
|
1120
|
-
"[permissions.board]",
|
|
1121
|
-
"extends = \":workspace\"",
|
|
1122
|
-
"",
|
|
1123
|
-
"[permissions.board.network]",
|
|
1124
|
-
"enabled = true",
|
|
1125
|
-
"",
|
|
1126
|
-
"[permissions.board.network.domains]",
|
|
1127
|
-
...[boardHost ?? "<your board host>", "api.vercel.com"].map((host) => `"${host}" = "allow"`)
|
|
1128
|
-
].join("\n")
|
|
1129
|
-
};
|
|
1130
|
-
}
|
|
1131
|
-
/** Names in words: `a`, `a or b`, `a, b, or c`. */
|
|
1132
|
-
const either = (names) => names.length < 3 ? names.join(" or ") : `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}`;
|
|
1133
|
-
/**
|
|
1134
|
-
* How an installed hook differs from what installing it again would write, as
|
|
1135
|
-
* short phrases (none when it's current): an older hook command or Pi
|
|
1136
|
-
* extension, rules for commands since removed, rules it doesn't have yet.
|
|
1137
|
-
* It keeps the CLI the hook runs, which `install` may choose differently.
|
|
1138
|
-
*/
|
|
1139
|
-
function outdated(agent, installed) {
|
|
1140
|
-
const spec = AGENTS[agent];
|
|
1141
|
-
const cli = cliOf(installed);
|
|
1142
|
-
const problems = [];
|
|
1143
|
-
if (installed !== hookCommand(agent, cli)) problems.push("runs an older hook command");
|
|
1144
|
-
if (spec.shape === "extension") {
|
|
1145
|
-
let source = "";
|
|
1146
|
-
try {
|
|
1147
|
-
source = readFileSync(spec.file(), "utf8");
|
|
1148
|
-
} catch {}
|
|
1149
|
-
if (source !== piExtension(installed)) problems.push("the extension is from another version");
|
|
1150
|
-
}
|
|
1151
|
-
const rules = allowRules(agent, cli);
|
|
1152
|
-
if (rules) {
|
|
1153
|
-
const allowed = readConfig(rules.file).permissions?.allow ?? [];
|
|
1154
|
-
const sub = (rule) => {
|
|
1155
|
-
const inner = /^\w+\((.*?)(?::\*)?\)$/.exec(rule)?.[1] ?? rule;
|
|
1156
|
-
return inner.startsWith(`${cli} `) ? inner.slice(cli.length + 1) : inner;
|
|
1157
|
-
};
|
|
1158
|
-
const gone = legacyRules(agent, cli).filter((rule) => allowed.includes(rule));
|
|
1159
|
-
const missing = rules.rules.filter((rule) => !allowed.includes(rule));
|
|
1160
|
-
if (gone.length > 0) problems.push(`allows ${either(gone.map(sub))}, which ${gone.length === 1 ? "is" : "are"} gone`);
|
|
1161
|
-
if (missing.length > 0) problems.push(`doesn't allow ${either(missing.map(sub))}`);
|
|
1162
|
-
}
|
|
1163
|
-
return problems;
|
|
1164
|
-
}
|
|
1165
|
-
/**
|
|
1166
|
-
* In the temp directory, which agents' sandboxes let commands write (Codex's
|
|
1167
|
-
* doesn't allow ~/.config): a marker only has to last a few turns, and the
|
|
1168
|
-
* system clears it with the rest of its temp files. `XDG_RUNTIME_DIR`, where
|
|
1169
|
-
* there is one, is the user's own.
|
|
1170
|
-
*/
|
|
1171
|
-
const turnsDir = () => join(process.env.XDG_RUNTIME_DIR || tmpdir(), `board-turns-${process.getuid?.() ?? "user"}`);
|
|
1172
|
-
const turnsFile = (session) => join(turnsDir(), `${session.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
1173
|
-
function readTurns(session) {
|
|
1174
|
-
try {
|
|
1175
|
-
return JSON.parse(readFileSync(turnsFile(session), "utf8"));
|
|
1176
|
-
} catch {
|
|
1177
|
-
return {};
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
/** The marker directory, only if it's ours: in a shared /tmp, someone else could have made it first. */
|
|
1181
|
-
function ownTurnsDir() {
|
|
1182
|
-
const dir = turnsDir();
|
|
1183
|
-
mkdirSync(dir, {
|
|
1184
|
-
recursive: true,
|
|
1185
|
-
mode: 448
|
|
1186
|
-
});
|
|
1187
|
-
const { uid, mode } = statSync(dir);
|
|
1188
|
-
if (process.getuid && uid !== process.getuid()) throw new Error(`${dir} belongs to someone else`);
|
|
1189
|
-
if ((mode & 63) !== 0) chmodSync(dir, 448);
|
|
1190
|
-
return dir;
|
|
1191
|
-
}
|
|
1192
|
-
function writeTurns(session, turns) {
|
|
1193
|
-
ownTurnsDir();
|
|
1194
|
-
writeFileSync(turnsFile(session), JSON.stringify(turns));
|
|
1195
|
-
}
|
|
1196
|
-
/** A write to the board from an agent session: this turn needs no reminder. Best effort: the write already happened. */
|
|
1197
|
-
function markPosted(session, now = Date.now()) {
|
|
1198
|
-
try {
|
|
1199
|
-
writeTurns(session, {
|
|
1200
|
-
...readTurns(session),
|
|
1201
|
-
posted: now
|
|
1202
|
-
});
|
|
1203
|
-
} catch {}
|
|
1204
|
-
}
|
|
1205
|
-
/**
|
|
1206
|
-
* The turn-end decision: let the agent stop, or remind it once to update the
|
|
1207
|
-
* board. It stops freely when it wrote to the board since its last turn
|
|
1208
|
-
* ended, when it was already reminded this turn, or when the agent says a
|
|
1209
|
-
* hook made it continue (`continuing`), so it can never loop.
|
|
1210
|
-
*/
|
|
1211
|
-
function decideStop(session, continuing, now = Date.now()) {
|
|
1212
|
-
const turns = readTurns(session);
|
|
1213
|
-
const since = turns.settled ?? 0;
|
|
1214
|
-
if (continuing || (turns.posted ?? 0) > since || (turns.asked ?? 0) > since) {
|
|
1215
|
-
writeTurns(session, {
|
|
1216
|
-
...turns,
|
|
1217
|
-
settled: now
|
|
1218
|
-
});
|
|
1219
|
-
return "stop";
|
|
1220
|
-
}
|
|
1221
|
-
writeTurns(session, {
|
|
1222
|
-
...turns,
|
|
1223
|
-
asked: now
|
|
1224
|
-
});
|
|
1225
|
-
return "ask";
|
|
1226
|
-
}
|
|
1227
|
-
/**
|
|
1228
|
-
* The reminder, with the exact commands to run. It doesn't pick a channel: the
|
|
1229
|
-
* agent replies in a thread it's working in, or finds the channel that fits.
|
|
1230
|
-
* Finishing without a write is fine, since the hook asks only once a turn.
|
|
1231
|
-
*/
|
|
1232
|
-
function reminder(cli) {
|
|
1233
|
-
return [
|
|
1234
|
-
"You didn't update the team's board this turn.",
|
|
1235
|
-
`If you changed anything or made progress, share a 1–3 sentence summary of what you did and what's next where the team will see it: ${cli} reply <id> "<text>" in a board thread you're working in, or ${cli} post <channel> "<text>" in the channel that fits (${cli} channels lists them, ${cli} read <channel> shows what's there). Add --attach <file> for proof, like a test log.`,
|
|
1236
|
-
`If you want to store memory, you can post to brain-<username>. Channels are public, so never store sensitive information.`,
|
|
1237
|
-
"If this turn changed nothing worth sharing (say, you just answered a question), just finish. Don't include the prompt, long output, or secrets."
|
|
1238
|
-
].join(" ");
|
|
1239
|
-
}
|
|
1240
|
-
/** Marks the offer made for `board`. True only for the call that made it, and never when it can't be saved. */
|
|
1241
|
-
function claimLinkOffer(board) {
|
|
1242
|
-
const file = join(configDir(), "link-offered", createHash("sha256").update(board).digest("hex").slice(0, 32));
|
|
1243
|
-
try {
|
|
1244
|
-
mkdirSync(dirname(file), {
|
|
1245
|
-
recursive: true,
|
|
1246
|
-
mode: 448
|
|
1247
|
-
});
|
|
1248
|
-
writeFileSync(file, "", { flag: "wx" });
|
|
1249
|
-
return true;
|
|
1250
|
-
} catch {
|
|
1251
|
-
return false;
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
function linkOffer(cli) {
|
|
1255
|
-
return [
|
|
1256
|
-
`One more thing, this time only: run ${cli} identity.`,
|
|
1257
|
-
"If it says some are not linked (say, slack or github), ask the person in one short question whether they'd like to link them, so the board knows their messages there are theirs and mentions reach them.",
|
|
1258
|
-
`If they say yes, run ${cli} identity link <key> --no-wait for each and give them the sign-in URL or code it prints: only they should open or post it.`,
|
|
1259
|
-
"If they say no, or there's nothing to link, drop it. This won't come up again."
|
|
1260
|
-
].join(" ");
|
|
1261
|
-
}
|
|
1262
|
-
/**
|
|
1263
|
-
* The reminder as each agent reads it from the hook's stdout. Claude Code
|
|
1264
|
-
* shows a stop's `additionalContext` once, as hook feedback, and continues the
|
|
1265
|
-
* turn; a blocked stop would also show it as a hook error. Codex and Pi's
|
|
1266
|
-
* extension read a blocked stop's `reason`; Cursor, whose stop can't block,
|
|
1267
|
-
* a `followup_message`.
|
|
1268
|
-
*/
|
|
1269
|
-
function stopOutput(agent, text) {
|
|
1270
|
-
if (agent === "claude") return { hookSpecificOutput: {
|
|
1271
|
-
hookEventName: "Stop",
|
|
1272
|
-
additionalContext: text
|
|
1273
|
-
} };
|
|
1274
|
-
if (agent === "cursor") return { followup_message: text };
|
|
1275
|
-
return {
|
|
1276
|
-
decision: "block",
|
|
1277
|
-
reason: text
|
|
596
|
+
//#region src/cli/local.ts
|
|
597
|
+
/** Until Ctrl-C or a SIGTERM, whichever comes first: then neither is listened for. */
|
|
598
|
+
const stopped = () => new Promise((resolve) => {
|
|
599
|
+
const done = () => {
|
|
600
|
+
process.off("SIGINT", done);
|
|
601
|
+
process.off("SIGTERM", done);
|
|
602
|
+
resolve();
|
|
1278
603
|
};
|
|
1279
|
-
|
|
604
|
+
process.once("SIGINT", done);
|
|
605
|
+
process.once("SIGTERM", done);
|
|
606
|
+
});
|
|
1280
607
|
//#endregion
|
|
1281
|
-
//#region src/cli/
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
identity your linked Slack and GitHub accounts
|
|
1293
|
-
identity link <key> [--code] [--no-wait] link your account on sync entry <key> (e.g. slack): sign in, or post a code from it
|
|
1294
|
-
identity unlink <account> unlink one, by handle or actor ID
|
|
1295
|
-
|
|
1296
|
-
read [channel] [filters] [--limit N] the latest threads, one each: root, replies, latest reply
|
|
1297
|
-
(no channel: every channel)
|
|
1298
|
-
read <id> one thread, every message (filters: the root and the replies that match)
|
|
1299
|
-
read <link> the thread a Slack or GitHub link names, if the board synced it
|
|
1300
|
-
|
|
1301
|
-
post <channel|id> ["<text>"] [--label k=v]... [--attach <file>]... [--sync <target>]... [--no-sync] [--id <key>]
|
|
1302
|
-
a new thread in a channel, or with an id, a reply in its thread
|
|
1303
|
-
reply <id> ["<text>"] [--attach <file>]... [--id <key>] [--no-sync]
|
|
1304
|
-
schedule <channel|id> ["<text>"] (--in 1d | --at <time> | --every 30m) [--until <time> | --for 24h | --times N]
|
|
1305
|
-
[--label k=v]... [--attach <file>]... [--no-sync]
|
|
1306
|
-
post later, as you: once, or repeating until a stop or until canceled
|
|
1307
|
-
schedules [id] [--by <handle>] [--channel <c>] [--all]
|
|
1308
|
-
pending schedules (a thread's, with an id); --all adds done and canceled
|
|
1309
|
-
schedule cancel <schedule> stop one, e.g. schedule cancel s12 (yours, or with manage)
|
|
1310
|
-
edit <id> "<text>"
|
|
1311
|
-
delete <id>
|
|
1312
|
-
react <id> <emoji> [--remove]
|
|
1313
|
-
label <id> k=v... [--unset k]...
|
|
1314
|
-
typing <id> [status] show typing in the thread's synced platform threads (marks WhatsApp read);
|
|
1315
|
-
Slack shows the status, if given, instead of "Typing…"
|
|
1316
|
-
sync <id> where the thread is synced, with this message's link on each
|
|
1317
|
-
sync <id> <target> attach the thread to e.g. slack:#incidents, backfilling it
|
|
1318
|
-
inbox <key> [channel] [--limit N] raw platform events, for debugging sync
|
|
1319
|
-
sources the board's sources (its sync entries: slack, github, …)
|
|
1320
|
-
sources <key> read <channel|thread|link> [--limit N]
|
|
1321
|
-
read a source's channel, thread, or message link without syncing it,
|
|
1322
|
-
e.g. sources slack read '#general', or a Slack permalink (full access only)
|
|
1323
|
-
download <attachment> [path] save an attachment (its name, here, by default; - for stdout)
|
|
1324
|
-
conformance [--channel <c>] check that the board speaks the protocol: posts to conformance,
|
|
1325
|
-
kept on the board, and deletes what it posted (1 if a check fails)
|
|
1326
|
-
|
|
1327
|
-
hooks [install|uninstall|status] [claude|codex|cursor|pi]...
|
|
1328
|
-
after a turn that didn't update the board, remind the agent to (linked projects only)
|
|
1329
|
-
|
|
1330
|
-
tail [channel] [filters] [--all] live, until Ctrl-C
|
|
1331
|
-
wait [channel] [filters] [--timeout 30m] block for the next match, print it, then exit (124 on timeout)
|
|
1332
|
-
|
|
1333
|
-
inspect [--port N] [--no-open] the board in your browser, as you: read, post, react (local only)
|
|
1334
|
-
serve [--port N] [--dir <dir>] [--no-inspect] [--no-open]
|
|
1335
|
-
run a board on this machine (127.0.0.1:4100, no sync), link this
|
|
1336
|
-
project to it, and open the inspector; its log lives in .board/,
|
|
1337
|
-
and you're local:user:<your OS user> (or --handle)
|
|
1338
|
-
|
|
1339
|
-
filters: --re <id> --by <handle> --from <source> --label k[=v|w] --text <words> --mentions [@handle] --after <id>
|
|
1340
|
-
labels match a message or its thread's root; repeated flags all must match
|
|
1341
|
-
global: --url <url> --as <profile> --avatar <url> --human --handle <h> --json
|
|
1342
|
-
json: one object per line (JSONL): { message, replies, last } for read <channel>, a message for read <id>
|
|
1343
|
-
text: pass "-" to read it from stdin; it may be left out when you --attach
|
|
1344
|
-
env: BOARD_URL BOARD_TOKEN BOARD_AS BOARD_AVATAR BOARD_HANDLE`;
|
|
1345
|
-
var UsageError = class extends Error {};
|
|
1346
|
-
const OPTIONS = {
|
|
1347
|
-
...FILTER_FLAGS,
|
|
1348
|
-
url: { type: "string" },
|
|
1349
|
-
as: { type: "string" },
|
|
1350
|
-
avatar: { type: "string" },
|
|
1351
|
-
human: { type: "boolean" },
|
|
1352
|
-
handle: { type: "string" },
|
|
1353
|
-
json: { type: "boolean" },
|
|
1354
|
-
help: {
|
|
1355
|
-
type: "boolean",
|
|
1356
|
-
short: "h"
|
|
1357
|
-
},
|
|
1358
|
-
version: {
|
|
1359
|
-
type: "boolean",
|
|
1360
|
-
short: "v"
|
|
1361
|
-
},
|
|
1362
|
-
unset: {
|
|
1363
|
-
type: "string",
|
|
1364
|
-
multiple: true
|
|
1365
|
-
},
|
|
1366
|
-
id: { type: "string" },
|
|
1367
|
-
"no-sync": { type: "boolean" },
|
|
1368
|
-
sync: {
|
|
1369
|
-
type: "string",
|
|
1370
|
-
multiple: true
|
|
1371
|
-
},
|
|
1372
|
-
attach: {
|
|
1373
|
-
type: "string",
|
|
1374
|
-
multiple: true
|
|
1375
|
-
},
|
|
1376
|
-
channel: { type: "string" },
|
|
1377
|
-
agent: { type: "string" },
|
|
1378
|
-
remove: { type: "boolean" },
|
|
1379
|
-
after: { type: "string" },
|
|
1380
|
-
limit: { type: "string" },
|
|
1381
|
-
all: { type: "boolean" },
|
|
1382
|
-
timeout: { type: "string" },
|
|
1383
|
-
port: { type: "string" },
|
|
1384
|
-
"no-open": { type: "boolean" },
|
|
1385
|
-
"no-inspect": { type: "boolean" },
|
|
1386
|
-
dir: { type: "string" },
|
|
1387
|
-
code: { type: "boolean" },
|
|
1388
|
-
"no-wait": { type: "boolean" },
|
|
1389
|
-
in: { type: "string" },
|
|
1390
|
-
at: { type: "string" },
|
|
1391
|
-
every: { type: "string" },
|
|
1392
|
-
until: { type: "string" },
|
|
1393
|
-
for: { type: "string" },
|
|
1394
|
-
times: { type: "string" }
|
|
1395
|
-
};
|
|
1396
|
-
const MARK = "\0";
|
|
1397
|
-
/**
|
|
1398
|
-
* Attachment IDs are base64url, so boards minted some that start with `-`,
|
|
1399
|
-
* which `parseArgs` reads as a flag. Where `download` takes its ID, a token
|
|
1400
|
-
* shaped like one is the ID: this marks it to parse as a positional.
|
|
1401
|
-
*/
|
|
1402
|
-
function markAttachmentId(args) {
|
|
1403
|
-
const positionals = [];
|
|
1404
|
-
for (let i = 0; i < args.length; i++) {
|
|
1405
|
-
const arg = args[i];
|
|
1406
|
-
if (arg === "--") break;
|
|
1407
|
-
if (!arg.startsWith("-") || arg === "-") positionals.push(arg);
|
|
1408
|
-
else if (positionals.length === 1 && positionals[0] === "download" && AttachmentId.safeParse(arg).success) return [
|
|
1409
|
-
...args.slice(0, i),
|
|
1410
|
-
`${MARK}${arg}`,
|
|
1411
|
-
...args.slice(i + 1)
|
|
1412
|
-
];
|
|
1413
|
-
else if (!arg.includes("=") && OPTIONS[arg.slice(2)]?.type === "string") i++;
|
|
1414
|
-
}
|
|
1415
|
-
return args;
|
|
1416
|
-
}
|
|
1417
|
-
/** Commands that are gone, and what to run instead. Their flags don't matter: an old hook's `vb update --skip` gets this too. */
|
|
1418
|
-
const REMOVED = {
|
|
1419
|
-
update: "`vb update` is gone: reply in the thread you're working in (vb reply <id>), or post where it fits (vb post <channel>)",
|
|
1420
|
-
stuck: "`vb stuck` is gone: a board on a2() keeps its dead letters in A2, on the backend's `servers`"
|
|
1421
|
-
};
|
|
1422
|
-
/** The command word: the first argument that isn't a flag or a string flag's value. */
|
|
1423
|
-
function commandWord(args) {
|
|
1424
|
-
for (let i = 0; i < args.length; i++) {
|
|
1425
|
-
const arg = args[i];
|
|
1426
|
-
if (arg === "--") return args[i + 1];
|
|
1427
|
-
if (!arg.startsWith("-") || arg === "-") return arg;
|
|
1428
|
-
if (!arg.includes("=") && OPTIONS[arg.slice(2)]?.type === "string") i++;
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
const parsed = (() => {
|
|
1432
|
-
const args = withMentions(process.argv.slice(2));
|
|
1433
|
-
const removed = REMOVED[commandWord(args) ?? ""];
|
|
1434
|
-
try {
|
|
1435
|
-
if (removed) throw new UsageError(removed);
|
|
1436
|
-
return parseArgs({
|
|
1437
|
-
args: markAttachmentId(args),
|
|
1438
|
-
allowPositionals: true,
|
|
1439
|
-
options: OPTIONS
|
|
1440
|
-
});
|
|
1441
|
-
} catch (error) {
|
|
1442
|
-
const unknown = error.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" && /'([^']+)'/.exec(error.message)?.[1];
|
|
1443
|
-
process.stderr.write(terminalText(`error: ${unknown ? `unknown option: ${unknown} (run \`board help\`; text starting with - goes after --, as in -- "${unknown}")` : error.message}`) + "\n");
|
|
1444
|
-
return null;
|
|
1445
|
-
}
|
|
1446
|
-
})();
|
|
1447
|
-
const { values: flags, positionals: marked } = parsed ?? {
|
|
1448
|
-
values: {},
|
|
1449
|
-
positionals: []
|
|
1450
|
-
};
|
|
1451
|
-
const positionals = marked.map((arg) => arg.startsWith(MARK) ? arg.slice(1) : arg);
|
|
1452
|
-
const out = (text) => process.stdout.write(`${terminalText(text)}\n`);
|
|
1453
|
-
const printJson = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
1454
|
-
/** A message's ID as you'd write it: `#812` or `812`. It's passed to the board as it is. */
|
|
1455
|
-
function messageId(value, what = "message id") {
|
|
1456
|
-
const id = value?.replace(/^#/, "") ?? "";
|
|
1457
|
-
if (!/^[^\s/]+$/.test(id)) throw new UsageError(`expected a ${what}, got: ${value ?? "(nothing)"}`);
|
|
1458
|
-
return id;
|
|
1459
|
-
}
|
|
1460
|
-
/** Whether an argument names a message rather than a channel: `#<id>`, or a bare number. */
|
|
1461
|
-
const isMessageRef = (value) => /^#[^\s/]+$/.test(value) || /^\d+$/.test(value);
|
|
1462
|
-
function labelPairs(values) {
|
|
1463
|
-
const pairs = {};
|
|
1464
|
-
for (const value of values ?? []) {
|
|
1465
|
-
const at = value.indexOf("=");
|
|
1466
|
-
if (at <= 0) throw new UsageError(`labels look like key=value, got: ${value}`);
|
|
1467
|
-
pairs[value.slice(0, at)] = value.slice(at + 1);
|
|
1468
|
-
}
|
|
1469
|
-
return pairs;
|
|
1470
|
-
}
|
|
1471
|
-
async function text(value) {
|
|
1472
|
-
if (value === void 0) throw new UsageError("missing message text");
|
|
1473
|
-
if (value !== "-") return value;
|
|
1474
|
-
const chunks = [];
|
|
1475
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1476
|
-
return Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
|
|
1477
|
-
}
|
|
1478
|
-
/** Uploads each `--attach` file, checking them all before sending any. */
|
|
1479
|
-
async function attachments(board) {
|
|
1480
|
-
const paths = flags.attach ?? [];
|
|
1481
|
-
for (const path of paths) {
|
|
1482
|
-
const info = await stat(path).catch(() => null);
|
|
1483
|
-
if (!info?.isFile()) throw new UsageError(`no such file: ${path}`);
|
|
1484
|
-
if (info.size > 104857600) throw new UsageError(`${path} is over ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB, the most an attachment can be`);
|
|
1485
|
-
}
|
|
1486
|
-
const uploaded = [];
|
|
1487
|
-
for (const path of paths) uploaded.push(await board.upload({
|
|
1488
|
-
data: new Uint8Array(await readFile(path)),
|
|
1489
|
-
name: basename(path)
|
|
1490
|
-
}));
|
|
1491
|
-
return uploaded;
|
|
1492
|
-
}
|
|
1493
|
-
/** `s12` or `12`. */
|
|
1494
|
-
function scheduleId(value) {
|
|
1495
|
-
const id = value?.replace(/^s(?=\d)/i, "") ?? "";
|
|
1496
|
-
if (!/^[^\s/]+$/.test(id)) throw new UsageError(`expected a schedule id like s12, got: ${value ?? "(nothing)"}`);
|
|
1497
|
-
return id;
|
|
1498
|
-
}
|
|
1499
|
-
/** A time flag: anything `Date` reads, as ISO. */
|
|
1500
|
-
function instant(flag, value) {
|
|
1501
|
-
const time = new Date(value);
|
|
1502
|
-
if (Number.isNaN(time.getTime())) throw new UsageError(`--${flag} takes a time like 2026-10-08T09:00:00Z, got: ${value}`);
|
|
1503
|
-
return time.toISOString();
|
|
1504
|
-
}
|
|
1505
|
-
function filters(channel) {
|
|
1506
|
-
return {
|
|
1507
|
-
...channel ? { channel } : {},
|
|
1508
|
-
...flags.re ? { re: messageId(flags.re) } : {},
|
|
1509
|
-
...flags.by ? { by: flags.by.replace(/^@/, "") } : {},
|
|
1510
|
-
...flags.mentions ? { mentions: flags.mentions.replace(/^@/, "") } : {},
|
|
1511
|
-
...flags.label?.length ? { label: flags.label } : {},
|
|
1512
|
-
...flags.from ? { from: flags.from } : {},
|
|
1513
|
-
...flags.text ? { text: flags.text } : {},
|
|
1514
|
-
...flags.after !== void 0 ? { after: flags.after.replace(/^#/, "") } : {}
|
|
1515
|
-
};
|
|
1516
|
-
}
|
|
1517
|
-
/**
|
|
1518
|
-
* The board this command talks to, `--url`'s or the one the environment points at, as `--as`
|
|
1519
|
-
* says: a handle on it, and how it reaches it (the credentials `vb` finds, `--handle`'s handle).
|
|
1520
|
-
*/
|
|
1521
|
-
function connection(url = flags.url, options = {}) {
|
|
1522
|
-
const at = (url ?? boardUrl()).replace(/\/+$/, "");
|
|
1523
|
-
const handle = options.handle ?? flags.handle;
|
|
1524
|
-
const transport = connect(at, {
|
|
1525
|
-
...options.token !== void 0 ? { token: options.token } : {},
|
|
1526
|
-
...handle ? { handle } : {}
|
|
1527
|
-
});
|
|
1528
|
-
const board$1 = board({ backend: http(at, { fetch: transport.fetch }) });
|
|
1529
|
-
const profile = flags.human ? null : flags.as ? flags.avatar ? {
|
|
1530
|
-
name: flags.as,
|
|
1531
|
-
avatar: flags.avatar
|
|
1532
|
-
} : flags.as : void 0;
|
|
1533
|
-
return {
|
|
1534
|
-
url: at,
|
|
1535
|
-
transport,
|
|
1536
|
-
board: profile !== void 0 ? board$1.as(profile) : board$1
|
|
1537
|
-
};
|
|
1538
|
-
}
|
|
1539
|
-
/** A handle on the board this command talks to. */
|
|
1540
|
-
const client = () => connection().board;
|
|
1541
|
-
/**
|
|
1542
|
-
* What names an agent's turns, for the turn-end hook: its session, or for
|
|
1543
|
-
* Cursor, whose shell has no session ID, the project it works in.
|
|
1544
|
-
*/
|
|
1545
|
-
const turnKey = (agent, session) => session ?? (agent === "cursor" ? `cursor@${projectDir()}` : null);
|
|
1546
|
-
/** A write from an agent session counts as this turn's board update. */
|
|
1547
|
-
function wrote() {
|
|
1548
|
-
const detected = flags.human ? null : detectAgent();
|
|
1549
|
-
const key = detected ? turnKey(detected.profile, detected.session) : null;
|
|
1550
|
-
if (key) markPosted(key);
|
|
1551
|
-
}
|
|
1552
|
-
const fromNpx = (path) => /[\\/]_npx[\\/]/.test(path);
|
|
1553
|
-
/** The first `vb` on PATH outside npx's cache, which is gone once npx exits. */
|
|
1554
|
-
function globalVb() {
|
|
1555
|
-
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
1556
|
-
const candidate = join(dir, "vb");
|
|
1557
|
-
if (existsSync(candidate) && !fromNpx(realpathSync(candidate))) return candidate;
|
|
1558
|
-
}
|
|
1559
|
-
return null;
|
|
1560
|
-
}
|
|
1561
|
-
/**
|
|
1562
|
-
* Whether `bin` runs the CLI at `self`: a symlink to it (npm), or a shell
|
|
1563
|
-
* shim that execs node on it (pnpm, through `$basedir`- or `$basedir_abs`-relative paths).
|
|
1564
|
-
*/
|
|
1565
|
-
function runsCli(bin, self) {
|
|
1566
|
-
try {
|
|
1567
|
-
const real = realpathSync(bin);
|
|
1568
|
-
if (real === self) return true;
|
|
1569
|
-
if (statSync(real).size > 65536) return false;
|
|
1570
|
-
const script = readFileSync(real, "utf8");
|
|
1571
|
-
if (!script.startsWith("#!")) return false;
|
|
1572
|
-
const basedir = dirname(bin);
|
|
1573
|
-
const paths = [...[...script.matchAll(/"([^"\n]+)"/g)].map(([, quoted]) => quoted.replace(/^\$(?:\{basedir(?:_abs)?\}|basedir(?:_abs)?)(?=\/)/, basedir)), ...[...script.matchAll(/^# cmd-shim-target=(.+)$/gm)].map(([, target]) => target.trim())];
|
|
1574
|
-
for (const path of paths) if (isAbsolute(path) && existsSync(path) && realpathSync(path) === self) return true;
|
|
1575
|
-
} catch {}
|
|
1576
|
-
return false;
|
|
1577
|
-
}
|
|
1578
|
-
/**
|
|
1579
|
-
* How an agent's hook should run this CLI, long after this command exits:
|
|
1580
|
-
* `vb` when that's this CLI; under npx, a global `vb` of this version, else
|
|
1581
|
-
* this version through npx; otherwise this CLI's full path.
|
|
1582
|
-
*/
|
|
1583
|
-
function cliCommand() {
|
|
1584
|
-
const self = realpathSync(process.argv[1]);
|
|
1585
|
-
const installed = globalVb();
|
|
1586
|
-
if (!fromNpx(self)) {
|
|
1587
|
-
if (installed && runsCli(installed, self)) return { command: "vb" };
|
|
1588
|
-
const quote = (part) => /^[\w./-]+$/.test(part) ? part : `'${part.replace(/'/g, `'\\''`)}'`;
|
|
1589
|
-
return { command: [process.execPath, self].map(quote).join(" ") };
|
|
1590
|
-
}
|
|
1591
|
-
let version = "";
|
|
1592
|
-
try {
|
|
1593
|
-
version = installed ? execFileSync(installed, ["--version"], {
|
|
1594
|
-
encoding: "utf8",
|
|
1595
|
-
timeout: 5e3,
|
|
1596
|
-
stdio: [
|
|
1597
|
-
"ignore",
|
|
1598
|
-
"pipe",
|
|
1599
|
-
"ignore"
|
|
1600
|
-
]
|
|
1601
|
-
}).trim() : "";
|
|
1602
|
-
} catch {}
|
|
1603
|
-
if (version === VERSION) return { command: "vb" };
|
|
1604
|
-
return {
|
|
1605
|
-
command: `npx -y @vercel/board@${VERSION}`,
|
|
1606
|
-
note: `the hook runs board ${VERSION} through npx${installed ? ` (your vb is ${version || "older"})` : ""}; \`npm i -g @vercel/board\` makes it start faster`
|
|
1607
|
-
};
|
|
1608
|
-
}
|
|
1609
|
-
/**
|
|
1610
|
-
* The board version a hook's CLI runs: pinned in an npx command, else what it
|
|
1611
|
-
* answers to `--version` (the first `vb` on PATH, for plain `vb`). Null when
|
|
1612
|
-
* it doesn't answer with one.
|
|
1613
|
-
*/
|
|
1614
|
-
function hookVersion(cli) {
|
|
1615
|
-
const pinned = /^npx -y @vercel\/board@(\S+)$/.exec(cli);
|
|
1616
|
-
if (pinned) return pinned[1];
|
|
1617
|
-
try {
|
|
1618
|
-
const answer = execFileSync("/bin/sh", ["-c", `${cli} --version`], {
|
|
1619
|
-
encoding: "utf8",
|
|
1620
|
-
timeout: 5e3,
|
|
1621
|
-
stdio: [
|
|
1622
|
-
"ignore",
|
|
1623
|
-
"pipe",
|
|
1624
|
-
"ignore"
|
|
1625
|
-
]
|
|
1626
|
-
}).trim();
|
|
1627
|
-
return /^\d+\.\d+\.\d+\S*$/.test(answer) ? answer : null;
|
|
1628
|
-
} catch {
|
|
1629
|
-
return null;
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
/** Whether version `a` comes before `b`, by major, minor, and patch. */
|
|
1633
|
-
function olderVersion(a, b) {
|
|
1634
|
-
const parts = (version) => version.split(/[.-]/).slice(0, 3).map(Number);
|
|
1635
|
-
const [x, y] = [parts(a), parts(b)];
|
|
1636
|
-
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return (x[i] ?? 0) < (y[i] ?? 0);
|
|
1637
|
-
return false;
|
|
1638
|
-
}
|
|
1639
|
-
async function readStdin() {
|
|
1640
|
-
const chunks = [];
|
|
1641
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
1642
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
1643
|
-
}
|
|
1644
|
-
function hookAgents(names) {
|
|
1645
|
-
const known = Object.keys(AGENTS);
|
|
1646
|
-
for (const name of names) if (!known.includes(name)) throw new UsageError(`unknown agent: ${name} (known: ${known.join(", ")})`);
|
|
1647
|
-
return names.length ? names : known;
|
|
1648
|
-
}
|
|
1649
|
-
/** A write went through, but some handles in it matched no one: they stayed plain text. */
|
|
1650
|
-
function warnUnknownMentions(handles) {
|
|
1651
|
-
if (!handles?.length) return;
|
|
1652
|
-
const names = handles.map((handle) => `@${handle}`);
|
|
1653
|
-
const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
1654
|
-
const one = names.length === 1;
|
|
1655
|
-
process.stderr.write(terminalText(`warning: ${list} ${one ? "matches" : "match"} no one on the board, so ${one ? "it stays" : "they stay"} plain text and ${one ? "notifies" : "notify"} no one`) + "\n");
|
|
1656
|
-
}
|
|
1657
|
-
function printMessage(message) {
|
|
1658
|
-
if (flags.json) printJson(message);
|
|
1659
|
-
else out(String(message.id));
|
|
1660
|
-
}
|
|
1661
|
-
function printEvent(event) {
|
|
1662
|
-
if (flags.json) printJson(event);
|
|
1663
|
-
else out(formatEvent(event));
|
|
1664
|
-
}
|
|
1665
|
-
/** Best-effort: the URL is printed either way. */
|
|
1666
|
-
/** `url`, when it's an http or https URL as `new URL` reads it: the only kind the CLI opens, so a board can't hand the opener a flag or another scheme. */
|
|
1667
|
-
function browsable(url) {
|
|
1668
|
-
try {
|
|
1669
|
-
const parsed = new URL(url);
|
|
1670
|
-
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.href : null;
|
|
1671
|
-
} catch {
|
|
1672
|
-
return null;
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
/** Opens an http(s) URL in the browser, as one argument to the opener: no shell reads it, Windows' included. */
|
|
1676
|
-
function openBrowser(url) {
|
|
1677
|
-
const at = browsable(url);
|
|
1678
|
-
if (at === null) return;
|
|
1679
|
-
const [command, ...args] = process.platform === "darwin" ? ["open", at] : process.platform === "win32" ? [
|
|
1680
|
-
"rundll32",
|
|
1681
|
-
"url.dll,FileProtocolHandler",
|
|
1682
|
-
at
|
|
1683
|
-
] : ["xdg-open", at];
|
|
1684
|
-
const child = spawn(command, args, {
|
|
1685
|
-
detached: true,
|
|
1686
|
-
stdio: "ignore"
|
|
1687
|
-
});
|
|
1688
|
-
child.on("error", () => {});
|
|
1689
|
-
child.unref();
|
|
1690
|
-
}
|
|
1691
|
-
/** Writes the board link: the project's `.board/` (kept out of Git), or the saved default when `root` is null. */
|
|
1692
|
-
async function saveLink(root, url, extra = {}) {
|
|
1693
|
-
const directory = root === null ? configDir() : join(root, ".board");
|
|
1694
|
-
if (root !== null) {
|
|
1695
|
-
const path = join(root, ".gitignore");
|
|
1696
|
-
const ignore = await readFile(path, "utf8").catch((error) => {
|
|
1697
|
-
if (error.code === "ENOENT") return "";
|
|
1698
|
-
throw error;
|
|
1699
|
-
});
|
|
1700
|
-
if (!ignore.split(/\r?\n/).some((line) => /^\/?\.board\/?$/.test(line))) await appendFile(path, `${ignore && !ignore.endsWith("\n") ? "\n" : ""}.board/\n`);
|
|
1701
|
-
}
|
|
1702
|
-
await mkdir(directory, {
|
|
1703
|
-
recursive: true,
|
|
1704
|
-
mode: 448
|
|
1705
|
-
});
|
|
1706
|
-
await writeFile(join(directory, "config.json"), `${JSON.stringify({
|
|
1707
|
-
url,
|
|
1708
|
-
...extra
|
|
1709
|
-
}, null, 2)}\n`, { mode: 384 });
|
|
1710
|
-
}
|
|
1711
|
-
/** Whether the project's link was written by `vb serve` (`"serve": true`), so a new run may move it. */
|
|
1712
|
-
function linkedByServe(root) {
|
|
1713
|
-
try {
|
|
1714
|
-
return JSON.parse(readFileSync(join(root, ".board", "config.json"), "utf8")).serve === true;
|
|
1715
|
-
} catch {
|
|
1716
|
-
return false;
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
function portFlag() {
|
|
1720
|
-
const port = flags.port === void 0 ? void 0 : Number(flags.port);
|
|
1721
|
-
if (port !== void 0 && !(Number.isInteger(port) && port >= 0 && port <= 65535)) throw new UsageError(`expected a port, got: ${flags.port}`);
|
|
1722
|
-
return port;
|
|
1723
|
-
}
|
|
1724
|
-
/** How long `identity link` waits for the sign-in or the code. */
|
|
1725
|
-
const LINK_WAIT_MS = 6e5;
|
|
1726
|
-
async function identity(args) {
|
|
1727
|
-
const board = client();
|
|
1728
|
-
const [sub, target] = args;
|
|
1729
|
-
if (sub === void 0) {
|
|
1730
|
-
const view = await board.identity();
|
|
1731
|
-
if (flags.json) return printJson(view), 0;
|
|
1732
|
-
if (view.links.length === 0) out("no linked accounts");
|
|
1733
|
-
for (const link of view.links) out(`${link.handle ? `@${link.handle} ` : ""}${link.actor} by ${link.method === "code" ? "code" : "sign-in"} · ${link.at}`);
|
|
1734
|
-
if (view.unlinked?.length) out(`not linked ${view.unlinked.join(", ")} (vb identity link <key>)`);
|
|
1735
|
-
return 0;
|
|
1736
|
-
}
|
|
1737
|
-
if (sub === "unlink") {
|
|
1738
|
-
if (!target || args.length !== 2) throw new UsageError("usage: board identity unlink <account> (a handle or actor ID from `vb identity`)");
|
|
1739
|
-
const result = await board.unlinkAccount(target);
|
|
1740
|
-
if (flags.json) printJson(result);
|
|
1741
|
-
else out(`unlinked ${result.actor}`);
|
|
1742
|
-
return 0;
|
|
1743
|
-
}
|
|
1744
|
-
if (sub !== "link" || !target || args.length !== 2) throw new UsageError("usage: board identity [link <key> [--code] [--no-wait] | unlink <account>]");
|
|
1745
|
-
const before = new Set((await board.identity()).links.map((link) => `${link.actor}|${link.at}`));
|
|
1746
|
-
const platform = (await board.discovery().catch(() => null))?.sync.find((entry) => entry.key === target)?.platform ?? target;
|
|
1747
|
-
const { code, expiresAt, signIn } = await board.syncCode(target);
|
|
1748
|
-
const consent = signIn !== void 0 && !flags.code;
|
|
1749
|
-
if (consent && browsable(signIn) === null) throw new Error(`the board's sign-in isn't an http or https URL: ${JSON.stringify(signIn)}`);
|
|
1750
|
-
if (flags.json) printJson({
|
|
1751
|
-
sync: target,
|
|
1752
|
-
platform,
|
|
1753
|
-
code,
|
|
1754
|
-
expiresAt,
|
|
1755
|
-
...signIn !== void 0 ? { signIn } : {}
|
|
1756
|
-
});
|
|
1757
|
-
const until = Date.now() + LINK_WAIT_MS;
|
|
1758
|
-
const sleep = () => new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
1759
|
-
/** An account linked on that platform since this run began: where the code or the sign-in landed. */
|
|
1760
|
-
const linkedSince = async () => (await board.identity()).links.find((link) => link.actor.startsWith(`${platform}:user:`) && !before.has(`${link.actor}|${link.at}`));
|
|
1761
|
-
if (consent) {
|
|
1762
|
-
if (!flags.json) {
|
|
1763
|
-
out(`Sign in to ${platform} as yourself to link it (and to post as you there):\n\n ${signIn}\n`);
|
|
1764
|
-
out(`Only open it yourself: whoever completes it links their account to you.`);
|
|
1765
|
-
}
|
|
1766
|
-
if (!flags["no-open"] && !flags.json) openBrowser(signIn);
|
|
1767
|
-
} else if (!flags.json) {
|
|
1768
|
-
const minutes = Math.round((Date.parse(expiresAt) - Date.now()) / 6e4);
|
|
1769
|
-
out(`From your ${platform} account, post this as a message anywhere the board syncs, within ${minutes} minutes:\n\n ${code}\n`);
|
|
1770
|
-
}
|
|
1771
|
-
if (flags["no-wait"]) return 0;
|
|
1772
|
-
while (Date.now() < Math.min(until, consent ? Infinity : Date.parse(expiresAt))) {
|
|
1773
|
-
await sleep();
|
|
1774
|
-
const linked = await linkedSince();
|
|
1775
|
-
if (linked) {
|
|
1776
|
-
if (flags.json) printJson(linked);
|
|
1777
|
-
else out(`linked ${linked.handle ? `@${linked.handle} ` : ""}(${linked.actor})`);
|
|
1778
|
-
return 0;
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
process.stderr.write("timed out\n");
|
|
1782
|
-
return 124;
|
|
1783
|
-
}
|
|
1784
|
-
async function run(command, args) {
|
|
1785
|
-
switch (command) {
|
|
1786
|
-
case void 0:
|
|
1787
|
-
case "help":
|
|
1788
|
-
out(HELP);
|
|
1789
|
-
return 0;
|
|
1790
|
-
case "link":
|
|
1791
|
-
case "use": {
|
|
1792
|
-
const url = args[0];
|
|
1793
|
-
if (!url || args.length !== 1) throw new UsageError(`usage: board ${command} <url>`);
|
|
1794
|
-
const discovery = await connection(url).board.discovery();
|
|
1795
|
-
const root = command === "link" ? projectDir() : null;
|
|
1796
|
-
await saveLink(root, discovery.url);
|
|
1797
|
-
out(root === null ? `using ${discovery.url}` : `linked ${root} to ${discovery.url}`);
|
|
1798
|
-
return 0;
|
|
1799
|
-
}
|
|
1800
|
-
case "whoami": {
|
|
1801
|
-
const { board, url, transport } = connection();
|
|
1802
|
-
const me = await board.me();
|
|
1803
|
-
const source = transport.source();
|
|
1804
|
-
if (flags.json) {
|
|
1805
|
-
printJson({
|
|
1806
|
-
...me,
|
|
1807
|
-
board: url,
|
|
1808
|
-
source
|
|
608
|
+
//#region src/cli/bin.ts
|
|
609
|
+
main(process.argv.slice(2), {
|
|
610
|
+
name: "board",
|
|
611
|
+
commands: {
|
|
612
|
+
inspect: {
|
|
613
|
+
help: " inspect [--port N] [--no-open] the board in your browser, as you: read, post, react (local only)",
|
|
614
|
+
async run(_args, { flags, out, portFlag, client, openBrowser }) {
|
|
615
|
+
const port = portFlag();
|
|
616
|
+
const inspector = await startInspector({
|
|
617
|
+
client: client(),
|
|
618
|
+
...port !== void 0 ? { port } : {}
|
|
1809
619
|
});
|
|
620
|
+
out(`inspecting ${inspector.board} as ${who(inspector.me)} at ${inspector.url} (Ctrl-C to stop)`);
|
|
621
|
+
if (!flags["no-open"]) openBrowser(inspector.url);
|
|
622
|
+
await stopped();
|
|
623
|
+
await inspector.close();
|
|
1810
624
|
return 0;
|
|
1811
625
|
}
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
out(`${prefix ? " " : ""}${channel.path} ${count(channel.threads, "thread", "threads")} · ${count(channel.messages, "message", "messages")} · last #${channel.lastIndex}`);
|
|
1846
|
-
}
|
|
1847
|
-
return 0;
|
|
1848
|
-
}
|
|
1849
|
-
case "actors": {
|
|
1850
|
-
const actors = await client().actors();
|
|
1851
|
-
if (flags.json) return actors.forEach(printJson), 0;
|
|
1852
|
-
for (const actor of actors) out(`@${actor.handle} ${actor.name} ${actor.id}${actor.person ? ` → @${actor.person.handle}` : ""}`);
|
|
1853
|
-
return 0;
|
|
1854
|
-
}
|
|
1855
|
-
case "identity": return identity(args);
|
|
1856
|
-
case "read": {
|
|
1857
|
-
const target = args[0];
|
|
1858
|
-
const board = client();
|
|
1859
|
-
const link = target !== void 0 && /^https?:\/\//i.test(target);
|
|
1860
|
-
if (target && (link || isMessageRef(target))) {
|
|
1861
|
-
const thread = link ? await board.linked(target) : await board.thread(messageId(target));
|
|
1862
|
-
if (link && !Array.isArray(thread.replies)) throw new Error("this board can't read links: update board where the board is deployed");
|
|
1863
|
-
const narrowed = Object.keys(filters()).length > 0;
|
|
1864
|
-
const replies = narrowed ? (await board.messages({
|
|
1865
|
-
...filters(),
|
|
1866
|
-
re: thread.message.id,
|
|
1867
|
-
limit: 500
|
|
1868
|
-
})).messages.filter((m) => m.re !== null) : thread.replies;
|
|
1869
|
-
if (flags.json) return [thread.message, ...replies].forEach(printJson), 0;
|
|
1870
|
-
const authors = new Set([thread.message, ...replies].map((m) => m.by.actor));
|
|
1871
|
-
const unnamed = reactionNames([thread.message, ...replies]).filter((id) => !authors.has(id));
|
|
1872
|
-
const known = unnamed.length ? new Map((await board.actors().catch(() => [])).filter((a) => unnamed.includes(a.id)).map((a) => [a.id, actorName(a)])) : /* @__PURE__ */ new Map();
|
|
1873
|
-
out(formatThreads([thread.message, ...replies], known));
|
|
1874
|
-
if (!narrowed) for (const schedule of thread.schedules ?? []) out(formatSchedule(schedule, { pad: " " }));
|
|
1875
|
-
return 0;
|
|
1876
|
-
}
|
|
1877
|
-
const query = {
|
|
1878
|
-
...filters(target),
|
|
1879
|
-
...flags.limit ? { limit: Number(flags.limit) } : {}
|
|
1880
|
-
};
|
|
1881
|
-
const old = /* @__PURE__ */ new Error("this board can't list threads: update board where the board is deployed");
|
|
1882
|
-
const page = await board.threads(query).catch((error) => {
|
|
1883
|
-
throw error instanceof BoardError && error.status === 404 ? old : error;
|
|
1884
|
-
});
|
|
1885
|
-
if (!Array.isArray(page.threads)) throw old;
|
|
1886
|
-
if (!page.threads.length && target && !(await board.channels()).some((c) => c.path === target || c.path.startsWith(target.replace(/\/?$/, "/")))) throw new Error(`no channel ${target}: board channels lists them`);
|
|
1887
|
-
if (flags.json) return page.threads.forEach(printJson), 0;
|
|
1888
|
-
const count = `${page.threads.length} ${page.threads.length === 1 ? "thread" : "threads"}`;
|
|
1889
|
-
out(`${target ?? "all channels"} · ${count} · cursor ${page.cursor}${page.threads.length ? "\n" : ""}`);
|
|
1890
|
-
if (page.threads.length) out(formatThreadSummaries(page.threads, { channel: !target || target.endsWith("/") }));
|
|
1891
|
-
return 0;
|
|
1892
|
-
}
|
|
1893
|
-
case "post": {
|
|
1894
|
-
const [target, body] = args;
|
|
1895
|
-
if (!target) throw new UsageError("usage: board post <channel|id> \"<text>\"");
|
|
1896
|
-
const re = isMessageRef(target) ? messageId(target) : void 0;
|
|
1897
|
-
if (re !== void 0 && flags.sync?.length) throw new UsageError("--sync attaches whole threads: post to a channel, or use vb sync <id> <target>");
|
|
1898
|
-
if (flags.sync?.length && flags["no-sync"]) throw new UsageError("--sync and --no-sync contradict each other");
|
|
1899
|
-
const board = client();
|
|
1900
|
-
const content = body === void 0 && flags.attach?.length ? "" : await text(body);
|
|
1901
|
-
const files = await attachments(board);
|
|
1902
|
-
const options = {
|
|
1903
|
-
text: content,
|
|
1904
|
-
...files.length ? { attachments: files } : {},
|
|
1905
|
-
...flags.label?.length ? { labels: labelPairs(flags.label) } : {},
|
|
1906
|
-
...flags.id ? { id: flags.id } : {},
|
|
1907
|
-
...flags["no-sync"] ? { noSync: true } : {}
|
|
1908
|
-
};
|
|
1909
|
-
const message = re === void 0 ? await board.post(target, {
|
|
1910
|
-
...options,
|
|
1911
|
-
...flags.sync?.length ? { sync: flags.sync } : {}
|
|
1912
|
-
}) : await board.reply(re, options);
|
|
1913
|
-
wrote();
|
|
1914
|
-
warnUnknownMentions(message.unknownMentions);
|
|
1915
|
-
printMessage(message);
|
|
1916
|
-
return 0;
|
|
1917
|
-
}
|
|
1918
|
-
case "reply": {
|
|
1919
|
-
const id = messageId(args[0]);
|
|
1920
|
-
const board = client();
|
|
1921
|
-
const content = args[1] === void 0 && flags.attach?.length ? "" : await text(args[1]);
|
|
1922
|
-
const files = await attachments(board);
|
|
1923
|
-
const message = await board.reply(id, {
|
|
1924
|
-
text: content,
|
|
1925
|
-
...files.length ? { attachments: files } : {},
|
|
1926
|
-
...flags.id ? { id: flags.id } : {},
|
|
1927
|
-
...flags["no-sync"] ? { noSync: true } : {}
|
|
1928
|
-
});
|
|
1929
|
-
wrote();
|
|
1930
|
-
warnUnknownMentions(message.unknownMentions);
|
|
1931
|
-
printMessage(message);
|
|
1932
|
-
return 0;
|
|
1933
|
-
}
|
|
1934
|
-
case "schedule": {
|
|
1935
|
-
if (args[0] === "cancel" && args.length === 2) {
|
|
1936
|
-
const { schedule, canceled } = await client().cancelSchedule(scheduleId(args[1]));
|
|
1937
|
-
if (flags.json) printJson(schedule);
|
|
1938
|
-
else out(canceled ? `canceled s${schedule.id}` : `s${schedule.id} is already ${schedule.state}`);
|
|
1939
|
-
return 0;
|
|
1940
|
-
}
|
|
1941
|
-
const [target, body] = args;
|
|
1942
|
-
if (!target) throw new UsageError("usage: board schedule <channel|id> \"<text>\" (--in 1d | --at <time> | --every 30m)");
|
|
1943
|
-
if (!flags.in && !flags.at && !flags.every) throw new UsageError("say when: --in 1d, --at <time>, or --every 30m");
|
|
1944
|
-
if (flags.sync?.length) throw new UsageError("a schedule posts where you point it: --sync is for post");
|
|
1945
|
-
const times = flags.times === void 0 ? void 0 : Number(flags.times);
|
|
1946
|
-
if (times !== void 0 && !(Number.isInteger(times) && times > 0)) throw new UsageError(`--times takes a count, got: ${flags.times}`);
|
|
1947
|
-
const board = client();
|
|
1948
|
-
const content = body === void 0 && flags.attach?.length ? "" : await text(body);
|
|
1949
|
-
const files = await attachments(board);
|
|
1950
|
-
const where = isMessageRef(target) ? { re: messageId(target) } : target;
|
|
1951
|
-
const schedule = await board.schedule(where, {
|
|
1952
|
-
text: content,
|
|
1953
|
-
...files.length ? { attachments: files } : {},
|
|
1954
|
-
...flags.label?.length ? { labels: labelPairs(flags.label) } : {},
|
|
1955
|
-
...flags["no-sync"] ? { noSync: true } : {},
|
|
1956
|
-
...flags.in ? { in: flags.in } : {},
|
|
1957
|
-
...flags.at ? { at: instant("at", flags.at) } : {},
|
|
1958
|
-
...flags.every ? { every: flags.every } : {},
|
|
1959
|
-
...flags.until ? { until: instant("until", flags.until) } : {},
|
|
1960
|
-
...flags.for ? { for: flags.for } : {},
|
|
1961
|
-
...times !== void 0 ? { times } : {}
|
|
1962
|
-
});
|
|
1963
|
-
wrote();
|
|
1964
|
-
warnUnknownMentions(schedule.unknownMentions);
|
|
1965
|
-
if (flags.json) printJson(schedule);
|
|
1966
|
-
else out(formatSchedule(schedule));
|
|
1967
|
-
return 0;
|
|
1968
|
-
}
|
|
1969
|
-
case "schedules": {
|
|
1970
|
-
const list = await client().schedules({
|
|
1971
|
-
...args[0] ? { re: messageId(args[0]) } : {},
|
|
1972
|
-
...flags.by ? { by: flags.by.replace(/^@/, "") } : {},
|
|
1973
|
-
...flags.channel ? { channel: flags.channel } : {},
|
|
1974
|
-
...flags.all ? { all: true } : {}
|
|
1975
|
-
});
|
|
1976
|
-
if (flags.json) return list.forEach(printJson), 0;
|
|
1977
|
-
if (list.length === 0) return out(flags.all ? "no schedules" : "no pending schedules"), 0;
|
|
1978
|
-
out(list.map((schedule) => formatSchedule(schedule)).join("\n"));
|
|
1979
|
-
return 0;
|
|
1980
|
-
}
|
|
1981
|
-
case "hooks": {
|
|
1982
|
-
const [action = "status", ...names] = args;
|
|
1983
|
-
const agents = hookAgents(names);
|
|
1984
|
-
const width = Math.max(...agents.map((agent) => AGENTS[agent].name.length));
|
|
1985
|
-
const row = (agent, ...cells) => out([AGENTS[agent].name.padEnd(width), ...cells].join(" ").trimEnd());
|
|
1986
|
-
if (action === "status") {
|
|
1987
|
-
const versions = /* @__PURE__ */ new Map();
|
|
1988
|
-
for (const agent of agents) {
|
|
1989
|
-
const installed = installedHook(agent);
|
|
1990
|
-
if (installed === null) {
|
|
1991
|
-
row(agent, "not installed");
|
|
1992
|
-
continue;
|
|
626
|
+
},
|
|
627
|
+
serve: {
|
|
628
|
+
help: ` serve [--port N] [--dir <dir>] [--no-inspect] [--no-open]
|
|
629
|
+
run a board on this machine (127.0.0.1:4100, no sync), link this
|
|
630
|
+
project to it, and open the inspector; its log lives in .board/,
|
|
631
|
+
and you're local:user:<your OS user> (or --handle)`,
|
|
632
|
+
async run(args, { flags, out, usage, portFlag, connection, openBrowser, saveLink, linkedByServe }) {
|
|
633
|
+
if (args.length > 0) throw usage("usage: board serve [--port N] [--dir <dir>] [--no-inspect] [--no-open]");
|
|
634
|
+
const port = portFlag();
|
|
635
|
+
const root = projectDir();
|
|
636
|
+
const { localHandle, serveLocal } = await import("./serve-1kBFSHe_.mjs");
|
|
637
|
+
const handle = flags.handle ?? process.env.BOARD_HANDLE;
|
|
638
|
+
const local = await serveLocal({
|
|
639
|
+
dir: flags.dir ?? join(root, ".board"),
|
|
640
|
+
...port !== void 0 ? { port } : {},
|
|
641
|
+
...handle ? { handle: localHandle(handle) } : {}
|
|
642
|
+
});
|
|
643
|
+
let inspector = null;
|
|
644
|
+
try {
|
|
645
|
+
const linked = projectUrl();
|
|
646
|
+
if (linked === void 0 || linked === local.url || linkedByServe(root)) {
|
|
647
|
+
await saveLink(root, local.url, { serve: true });
|
|
648
|
+
if (linked !== local.url) out(`linked ${root} to ${local.url}`);
|
|
649
|
+
} else out(`${root} stays linked to ${linked}; reach this board with BOARD_URL=${local.url}`);
|
|
650
|
+
out(`serving a local board at ${local.url} as ${local.principal}, its log in ${local.dir} (Ctrl-C to stop)`);
|
|
651
|
+
if (!flags["no-inspect"]) {
|
|
652
|
+
const { board } = connection(local.url, {
|
|
653
|
+
token: local.token,
|
|
654
|
+
handle: local.handle
|
|
655
|
+
});
|
|
656
|
+
inspector = await startInspector({ client: board.as(null) });
|
|
657
|
+
out(`inspector at ${inspector.url}`);
|
|
658
|
+
if (!flags["no-open"]) openBrowser(inspector.url);
|
|
1993
659
|
}
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
const behind = version != null && olderVersion(version, VERSION);
|
|
1999
|
-
if (behind) problems.push(`runs board ${version}, not ${VERSION}`);
|
|
2000
|
-
row(agent, (problems.length ? "outdated" : "installed").padEnd(9), tilde(AGENTS[agent].file()));
|
|
2001
|
-
if (problems.length === 0) continue;
|
|
2002
|
-
for (const problem of problems) out(` ${problem}`);
|
|
2003
|
-
const global = behind && cli === "vb";
|
|
2004
|
-
const fixes = [...global ? ["npm i -g @vercel/board"] : [], ...problems.length > (global ? 1 : 0) ? [`vb hooks install ${agent}`] : []];
|
|
2005
|
-
out(` fix with ${fixes.join(", then ")}`);
|
|
2006
|
-
}
|
|
2007
|
-
return 0;
|
|
2008
|
-
}
|
|
2009
|
-
if (action === "uninstall") {
|
|
2010
|
-
for (const agent of agents) {
|
|
2011
|
-
const installed = installedHook(agent);
|
|
2012
|
-
const cli = installed ? cliOf(installed) : null;
|
|
2013
|
-
const rules = cli ? allowRules(agent, cli) : null;
|
|
2014
|
-
if (rules) disallow(rules.file, [...rules.rules, ...legacyRules(agent, cli)]);
|
|
2015
|
-
row(agent, uninstallHook(agent) ? "removed" : "not installed", installed ? tilde(AGENTS[agent].file()) : "");
|
|
660
|
+
await stopped();
|
|
661
|
+
} finally {
|
|
662
|
+
await inspector?.close();
|
|
663
|
+
await local.close();
|
|
2016
664
|
}
|
|
2017
665
|
return 0;
|
|
2018
666
|
}
|
|
2019
|
-
if (action !== "install") throw new UsageError("usage: board hooks [install|uninstall|status] [claude|codex|cursor|pi]...");
|
|
2020
|
-
const { command: cli, note } = cliCommand();
|
|
2021
|
-
for (const agent of agents) {
|
|
2022
|
-
const had = installedHook(agent) !== null;
|
|
2023
|
-
const changed = installHook(agent, cli);
|
|
2024
|
-
const rules = allowRules(agent, cli);
|
|
2025
|
-
const added = rules ? allow(rules.file, rules.rules) : [];
|
|
2026
|
-
const dropped = rules ? disallow(rules.file, legacyRules(agent, cli)) : false;
|
|
2027
|
-
out(`${AGENTS[agent].name} ${tilde(AGENTS[agent].file())}`);
|
|
2028
|
-
out(` ${!had ? "added the turn-end hook" : changed || added.length > 0 || dropped ? "updated" : "already installed"}`);
|
|
2029
|
-
if (added.length > 0) out(` ${agent === "claude" ? "reply, post, read, and channels run without asking" : "vb runs without asking"}`);
|
|
2030
|
-
if (agent === "cursor" && !rules) out(" npm i -g @vercel/board lets Cursor run vb without asking");
|
|
2031
|
-
if (agent === "codex") {
|
|
2032
|
-
if (changed) out(" approve it once in Codex with /hooks");
|
|
2033
|
-
let host = null;
|
|
2034
|
-
try {
|
|
2035
|
-
const url = process.env.BOARD_URL || projectUrl();
|
|
2036
|
-
host = url ? new URL(url).hostname : null;
|
|
2037
|
-
} catch {}
|
|
2038
|
-
const config = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
|
|
2039
|
-
const profile = codexNetwork(host);
|
|
2040
|
-
out(` to let vb reach the board from Codex's sandbox, add to ${tilde(config)}:`);
|
|
2041
|
-
out("");
|
|
2042
|
-
out([
|
|
2043
|
-
"# a permission profile (beta) that opens the network to just the board",
|
|
2044
|
-
"",
|
|
2045
|
-
"# at the top, before any [table]",
|
|
2046
|
-
profile.top,
|
|
2047
|
-
"",
|
|
2048
|
-
"# anywhere (merge [features] into yours)",
|
|
2049
|
-
profile.tables
|
|
2050
|
-
].join("\n").replace(/^(?=.)/gm, " "));
|
|
2051
|
-
}
|
|
2052
|
-
out("");
|
|
2053
|
-
}
|
|
2054
|
-
if (note) out(`${note[0].toUpperCase()}${note.slice(1)}.`);
|
|
2055
|
-
out("The hooks remind once per turn without a board update.");
|
|
2056
|
-
out("They stay quiet outside projects linked with `vb link <url>`.");
|
|
2057
|
-
return 0;
|
|
2058
667
|
}
|
|
2059
|
-
case "hook": {
|
|
2060
|
-
if (args[0] !== "stop") throw new UsageError("usage: board hook stop --agent <agent> (run by an agent's turn-end hook)");
|
|
2061
|
-
const agent = flags.agent ?? "agent";
|
|
2062
|
-
try {
|
|
2063
|
-
const payload = JSON.parse(await readStdin());
|
|
2064
|
-
const cwd = payload.cwd ?? payload.workspace_roots?.[0];
|
|
2065
|
-
if (cwd && existsSync(cwd)) process.chdir(cwd);
|
|
2066
|
-
const session = turnKey(agent, agent === "cursor" ? null : payload.session_id ?? null);
|
|
2067
|
-
const boardUrl = process.env.BOARD_URL || projectUrl();
|
|
2068
|
-
if (!session || !boardUrl) return 0;
|
|
2069
|
-
if (payload.status && payload.status !== "completed") return 0;
|
|
2070
|
-
if (decideStop(session, payload.stop_hook_active === true || (payload.loop_count ?? 0) > 0) === "ask") {
|
|
2071
|
-
const cli = cliCommand().command;
|
|
2072
|
-
const text = claimLinkOffer(boardUrl) ? `${reminder(cli)}\n\n${linkOffer(cli)}` : reminder(cli);
|
|
2073
|
-
printJson(stopOutput(agent, text));
|
|
2074
|
-
}
|
|
2075
|
-
} catch {}
|
|
2076
|
-
return 0;
|
|
2077
|
-
}
|
|
2078
|
-
case "edit": {
|
|
2079
|
-
const { unknownMentions } = await client().edit(messageId(args[0]), await text(args[1]));
|
|
2080
|
-
warnUnknownMentions(unknownMentions);
|
|
2081
|
-
return 0;
|
|
2082
|
-
}
|
|
2083
|
-
case "delete":
|
|
2084
|
-
await client().delete(messageId(args[0]));
|
|
2085
|
-
return 0;
|
|
2086
|
-
case "react": {
|
|
2087
|
-
const id = messageId(args[0]);
|
|
2088
|
-
if (!args[1]) throw new UsageError("usage: board react <id> <emoji> [--remove]");
|
|
2089
|
-
let emoji;
|
|
2090
|
-
try {
|
|
2091
|
-
emoji = normalizeEmoji(args[1]);
|
|
2092
|
-
} catch (error) {
|
|
2093
|
-
throw new UsageError(error.message);
|
|
2094
|
-
}
|
|
2095
|
-
await client().react(id, emoji, flags.remove ? { remove: true } : {});
|
|
2096
|
-
return 0;
|
|
2097
|
-
}
|
|
2098
|
-
case "label": {
|
|
2099
|
-
const id = messageId(args[0]);
|
|
2100
|
-
const set = labelPairs(args.slice(1));
|
|
2101
|
-
const unset = flags.unset ?? [];
|
|
2102
|
-
if (Object.keys(set).length === 0 && unset.length === 0) throw new UsageError("usage: board label <id> k=v... [--unset k]...");
|
|
2103
|
-
await client().label(id, {
|
|
2104
|
-
set,
|
|
2105
|
-
unset
|
|
2106
|
-
});
|
|
2107
|
-
return 0;
|
|
2108
|
-
}
|
|
2109
|
-
case "typing": {
|
|
2110
|
-
const id = messageId(args[0]);
|
|
2111
|
-
const status = args.slice(1).join(" ").trim();
|
|
2112
|
-
const targets = await client().typing(id, status || void 0);
|
|
2113
|
-
if (flags.json) printJson({ targets });
|
|
2114
|
-
return 0;
|
|
2115
|
-
}
|
|
2116
|
-
case "sync": {
|
|
2117
|
-
const id = messageId(args[0]);
|
|
2118
|
-
if (!args[1]) {
|
|
2119
|
-
const { targets } = await client().sync(id);
|
|
2120
|
-
if (flags.json) return targets.forEach(printJson), 0;
|
|
2121
|
-
out(targets.length ? formatSynced(targets) : "not synced anywhere");
|
|
2122
|
-
return 0;
|
|
2123
|
-
}
|
|
2124
|
-
const result = await client().sync(id, args[1]);
|
|
2125
|
-
if (flags.json) printJson(result);
|
|
2126
|
-
else out(result.attached ? `synced with ${result.target}` : `already synced with ${result.target}`);
|
|
2127
|
-
return 0;
|
|
2128
|
-
}
|
|
2129
|
-
case "download": {
|
|
2130
|
-
const id = args[0];
|
|
2131
|
-
if (!id) throw new UsageError("usage: board download <attachment> [path] (the id `vb read` shows on a file line)");
|
|
2132
|
-
const response = await client().download(id);
|
|
2133
|
-
const data = new Uint8Array(await response.arrayBuffer());
|
|
2134
|
-
if (args[1] === "-") return process.stdout.write(data), 0;
|
|
2135
|
-
const disposition = response.headers.get("content-disposition") ?? "";
|
|
2136
|
-
const named = /filename\*=UTF-8''([^;]+)/.exec(disposition)?.[1];
|
|
2137
|
-
const path = args[1] ?? basename(named ? decodeURIComponent(named) : id);
|
|
2138
|
-
await writeFile(path, data);
|
|
2139
|
-
out(path);
|
|
2140
|
-
return 0;
|
|
2141
|
-
}
|
|
2142
|
-
case "inbox": {
|
|
2143
|
-
const key = args[0];
|
|
2144
|
-
if (!key) throw new UsageError("usage: board inbox <key> [channel] [--limit N]");
|
|
2145
|
-
const events = await client().inbox(key, {
|
|
2146
|
-
...args[1] ? { channel: args[1] } : {},
|
|
2147
|
-
...flags.limit ? { limit: Number(flags.limit) } : {}
|
|
2148
|
-
});
|
|
2149
|
-
if (flags.json) return events.forEach(printJson), 0;
|
|
2150
|
-
if (events.length === 0) return out("no events"), 0;
|
|
2151
|
-
for (const entry of events) {
|
|
2152
|
-
const event = entry.event;
|
|
2153
|
-
const detail = event.text ?? event.type ?? event.emoji ?? "";
|
|
2154
|
-
out(`${entry.createdAt} ${entry.channel} ${event.kind} ${event.author?.handle ?? ""} ${detail.split("\n")[0]}`);
|
|
2155
|
-
}
|
|
2156
|
-
return 0;
|
|
2157
|
-
}
|
|
2158
|
-
case "sources": {
|
|
2159
|
-
const [key, sub, target] = args;
|
|
2160
|
-
if (!key) {
|
|
2161
|
-
const { sync } = await client().discovery();
|
|
2162
|
-
if (flags.json) return sync.forEach(printJson), 0;
|
|
2163
|
-
if (sync.length === 0) return out("no sources: this board has no sync configured"), 0;
|
|
2164
|
-
for (const entry of sync) out(`${entry.key} ${entry.platform}`);
|
|
2165
|
-
return 0;
|
|
2166
|
-
}
|
|
2167
|
-
if (sub !== "read" || !target) throw new UsageError("usage: board sources <key> read <channel|thread|link> [--limit N]");
|
|
2168
|
-
const read = await client().readSource(key, target, flags.limit ? { limit: Number(flags.limit) } : {});
|
|
2169
|
-
if (flags.json) return printJson(read), 0;
|
|
2170
|
-
return out(formatSourceRead(key, read)), 0;
|
|
2171
|
-
}
|
|
2172
|
-
case "tail": {
|
|
2173
|
-
const controller = new AbortController();
|
|
2174
|
-
process.once("SIGINT", () => controller.abort());
|
|
2175
|
-
for await (const event of client().events({
|
|
2176
|
-
...filters(args[0]),
|
|
2177
|
-
...flags.all ? { all: true } : {}
|
|
2178
|
-
}, { signal: controller.signal })) printEvent(event);
|
|
2179
|
-
return 0;
|
|
2180
|
-
}
|
|
2181
|
-
case "wait": {
|
|
2182
|
-
const controller = new AbortController();
|
|
2183
|
-
process.once("SIGINT", () => controller.abort());
|
|
2184
|
-
const event = await client().wait(filters(args[0]), {
|
|
2185
|
-
timeout: flags.timeout ?? "30m",
|
|
2186
|
-
signal: controller.signal
|
|
2187
|
-
});
|
|
2188
|
-
if (!event) {
|
|
2189
|
-
if (!controller.signal.aborted) process.stderr.write("timed out\n");
|
|
2190
|
-
return 124;
|
|
2191
|
-
}
|
|
2192
|
-
printEvent(event);
|
|
2193
|
-
return 0;
|
|
2194
|
-
}
|
|
2195
|
-
case "serve": {
|
|
2196
|
-
if (args.length > 0) throw new UsageError("usage: board serve [--port N] [--dir <dir>] [--no-inspect] [--no-open]");
|
|
2197
|
-
const port = portFlag();
|
|
2198
|
-
const root = projectDir();
|
|
2199
|
-
const { localHandle, serveLocal } = await import("./serve-DB9ijyQ7.mjs");
|
|
2200
|
-
const handle = flags.handle ?? process.env.BOARD_HANDLE;
|
|
2201
|
-
const local = await serveLocal({
|
|
2202
|
-
dir: flags.dir ?? join(root, ".board"),
|
|
2203
|
-
...port !== void 0 ? { port } : {},
|
|
2204
|
-
...handle ? { handle: localHandle(handle) } : {}
|
|
2205
|
-
});
|
|
2206
|
-
let inspector = null;
|
|
2207
|
-
try {
|
|
2208
|
-
const linked = projectUrl();
|
|
2209
|
-
if (linked === void 0 || linked === local.url || linkedByServe(root)) {
|
|
2210
|
-
await saveLink(root, local.url, { serve: true });
|
|
2211
|
-
if (linked !== local.url) out(`linked ${root} to ${local.url}`);
|
|
2212
|
-
} else out(`${root} stays linked to ${linked}; reach this board with BOARD_URL=${local.url}`);
|
|
2213
|
-
out(`serving a local board at ${local.url} as ${local.principal}, its log in ${local.dir} (Ctrl-C to stop)`);
|
|
2214
|
-
if (!flags["no-inspect"]) {
|
|
2215
|
-
const { board } = connection(local.url, {
|
|
2216
|
-
token: local.token,
|
|
2217
|
-
handle: local.handle
|
|
2218
|
-
});
|
|
2219
|
-
inspector = await startInspector({ client: board.as(null) });
|
|
2220
|
-
out(`inspector at ${inspector.url}`);
|
|
2221
|
-
if (!flags["no-open"]) openBrowser(inspector.url);
|
|
2222
|
-
}
|
|
2223
|
-
await new Promise((resolve) => {
|
|
2224
|
-
process.once("SIGINT", resolve);
|
|
2225
|
-
process.once("SIGTERM", resolve);
|
|
2226
|
-
});
|
|
2227
|
-
} finally {
|
|
2228
|
-
await inspector?.close();
|
|
2229
|
-
await local.close();
|
|
2230
|
-
}
|
|
2231
|
-
return 0;
|
|
2232
|
-
}
|
|
2233
|
-
case "inspect": {
|
|
2234
|
-
const port = portFlag();
|
|
2235
|
-
const inspector = await startInspector({
|
|
2236
|
-
client: client(),
|
|
2237
|
-
...port !== void 0 ? { port } : {}
|
|
2238
|
-
});
|
|
2239
|
-
out(`inspecting ${inspector.board} as ${who(inspector.me)} at ${inspector.url} (Ctrl-C to stop)`);
|
|
2240
|
-
if (!flags["no-open"]) openBrowser(inspector.url);
|
|
2241
|
-
await new Promise((resolve) => {
|
|
2242
|
-
process.once("SIGINT", resolve);
|
|
2243
|
-
process.once("SIGTERM", resolve);
|
|
2244
|
-
});
|
|
2245
|
-
await inspector.close();
|
|
2246
|
-
return 0;
|
|
2247
|
-
}
|
|
2248
|
-
default: throw new UsageError(`unknown command: ${command} (run \`board help\`)`);
|
|
2249
668
|
}
|
|
2250
|
-
}
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
else {
|
|
2255
|
-
const [command, ...args] = positionals;
|
|
2256
|
-
run(command, args).then((code) => exit(code), (error) => {
|
|
2257
|
-
process.stderr.write(`${terminalText(`error: ${error.message}`)}\n`);
|
|
2258
|
-
exit(error instanceof UsageError ? 2 : 1);
|
|
2259
|
-
});
|
|
2260
|
-
}
|
|
669
|
+
}).then(exit, (error) => {
|
|
670
|
+
process.stderr.write(`error: ${error.message}\n`);
|
|
671
|
+
exit(1);
|
|
672
|
+
});
|
|
2261
673
|
/**
|
|
2262
674
|
* Exits once stdout and stderr have flushed. On a pipe, writes can still be
|
|
2263
675
|
* queued when a command returns, and `process.exit` would drop them: a long
|