kanbo-cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +2 -0
- package/dist/cli-main.cjs +628 -146
- package/dist/lib/index.cjs +1 -1
- package/dist/lib/index.d.cts +1 -1
- package/dist/lib/index.d.mts +1 -1
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/{manifest-QPoQl8tn.cjs → manifest-DcIMYvWk.cjs} +673 -191
- package/dist/lib/{manifest-v2e9x_C8.mjs → manifest-Dmo6IlWk.mjs} +674 -192
- package/dist/lib/mcp/index.cjs +12 -3
- package/dist/lib/mcp/index.d.cts +4 -0
- package/dist/lib/mcp/index.d.mts +4 -0
- package/dist/lib/mcp/index.mjs +12 -3
- package/dist/page/board.css +1622 -327
- package/dist/page/board.js +2 -2
- package/package.json +1 -1
package/dist/cli-main.cjs
CHANGED
|
@@ -357,6 +357,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
357
357
|
text: `No card "${named(details.issueId)}" here.`,
|
|
358
358
|
next: "kanbo card list"
|
|
359
359
|
}),
|
|
360
|
+
issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
|
|
360
361
|
issue_parent_not_found: (details) => ({
|
|
361
362
|
text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
|
|
362
363
|
next: "kanbo card list"
|
|
@@ -3560,7 +3561,7 @@ new Command();
|
|
|
3560
3561
|
* `package.version`. A build carries it as a literal; running from source
|
|
3561
3562
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
3562
3563
|
*/
|
|
3563
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
3564
|
+
const KANBO_PACKAGE_VERSION = "0.4.0";
|
|
3564
3565
|
//#endregion
|
|
3565
3566
|
//#region src/cli/agent-shell.ts
|
|
3566
3567
|
/**
|
|
@@ -3595,22 +3596,26 @@ const AGENT_SHELL_MARKERS = [
|
|
|
3595
3596
|
{
|
|
3596
3597
|
variable: "CLAUDECODE",
|
|
3597
3598
|
matches: (value) => value === "1",
|
|
3598
|
-
label: "CLAUDECODE=1"
|
|
3599
|
+
label: "CLAUDECODE=1",
|
|
3600
|
+
tool: "claude"
|
|
3599
3601
|
},
|
|
3600
3602
|
{
|
|
3601
3603
|
variable: "GEMINI_CLI",
|
|
3602
3604
|
matches: (value) => value === "1",
|
|
3603
|
-
label: "GEMINI_CLI=1"
|
|
3605
|
+
label: "GEMINI_CLI=1",
|
|
3606
|
+
tool: "gemini"
|
|
3604
3607
|
},
|
|
3605
3608
|
{
|
|
3606
3609
|
variable: "CURSOR_AGENT",
|
|
3607
3610
|
matches: (value) => value !== "",
|
|
3608
|
-
label: "CURSOR_AGENT"
|
|
3611
|
+
label: "CURSOR_AGENT",
|
|
3612
|
+
tool: "cursor"
|
|
3609
3613
|
},
|
|
3610
3614
|
{
|
|
3611
3615
|
variable: "CODEX_THREAD_ID",
|
|
3612
3616
|
matches: (value) => value !== "",
|
|
3613
|
-
label: "CODEX_THREAD_ID"
|
|
3617
|
+
label: "CODEX_THREAD_ID",
|
|
3618
|
+
tool: "codex"
|
|
3614
3619
|
}
|
|
3615
3620
|
];
|
|
3616
3621
|
/**
|
|
@@ -3627,6 +3632,15 @@ function readAgentShellMarker(env = process.env) {
|
|
|
3627
3632
|
if (kind === "person") return null;
|
|
3628
3633
|
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
|
|
3629
3634
|
}
|
|
3635
|
+
/**
|
|
3636
|
+
* The name of the agent tool this shell belongs to (`claude`, `gemini`,
|
|
3637
|
+
* `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
|
|
3638
|
+
* `null` for a person's shell. Same precedence as `readAgentShellMarker`.
|
|
3639
|
+
*/
|
|
3640
|
+
function readAgentTool(env = process.env) {
|
|
3641
|
+
if (readAgentShellMarker(env) === null) return null;
|
|
3642
|
+
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
|
|
3643
|
+
}
|
|
3630
3644
|
//#endregion
|
|
3631
3645
|
//#region src/cli/actor.ts
|
|
3632
3646
|
/**
|
|
@@ -3708,11 +3722,15 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
3708
3722
|
"run clear-session": describeRunSessionRefRefusal,
|
|
3709
3723
|
"run attach-session --replace": describeRunSessionRefRefusal
|
|
3710
3724
|
};
|
|
3711
|
-
/**
|
|
3725
|
+
/**
|
|
3726
|
+
* The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
|
|
3727
|
+
* else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
|
|
3728
|
+
* operating system's user.
|
|
3729
|
+
*/
|
|
3712
3730
|
function createCliActor() {
|
|
3713
3731
|
return {
|
|
3714
3732
|
kind: "external",
|
|
3715
|
-
id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
|
|
3733
|
+
id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
|
|
3716
3734
|
name: (0, node_os.hostname)()
|
|
3717
3735
|
};
|
|
3718
3736
|
}
|
|
@@ -3750,6 +3768,14 @@ function createPlacementActor() {
|
|
|
3750
3768
|
placesForPerson: true
|
|
3751
3769
|
};
|
|
3752
3770
|
}
|
|
3771
|
+
/** Who the board page writes as: the person at this machine — `KANBO_ACTOR_ID` in a person's shell, else the OS user; never an agent tool, since the page is read and typed in by a person. */
|
|
3772
|
+
function createServeWriter() {
|
|
3773
|
+
return {
|
|
3774
|
+
kind: "external",
|
|
3775
|
+
id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
|
|
3776
|
+
name: (0, node_os.hostname)()
|
|
3777
|
+
};
|
|
3778
|
+
}
|
|
3753
3779
|
/**
|
|
3754
3780
|
* The person who owns this shell, as the board files a person. Only for a
|
|
3755
3781
|
* caller that has already asked `isAgentShell` — `requireHumanActor` for a
|
|
@@ -11994,12 +12020,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
|
|
|
11994
12020
|
issueId,
|
|
11995
12021
|
parentIssueId
|
|
11996
12022
|
});
|
|
11997
|
-
|
|
12023
|
+
const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
|
|
12024
|
+
if (!parent) throw new BoardError("issue_parent_not_found", {
|
|
11998
12025
|
workspaceId,
|
|
11999
12026
|
parentIssueId
|
|
12000
12027
|
});
|
|
12028
|
+
if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new BoardError("issue_parent_cycle", {
|
|
12029
|
+
issueId,
|
|
12030
|
+
parentIssueId
|
|
12031
|
+
});
|
|
12001
12032
|
return parentIssueId;
|
|
12002
12033
|
}
|
|
12034
|
+
/**
|
|
12035
|
+
* Is the card somewhere under `ancestorId` — its parent, its parent's parent,
|
|
12036
|
+
* and so on up? A card put under one of its own sub-cards would make a loop no
|
|
12037
|
+
* reader of the board can walk to the top of. The cards already seen end the
|
|
12038
|
+
* walk, so a loop the data holds from before cannot hang it.
|
|
12039
|
+
*/
|
|
12040
|
+
async function isUnder(store, card, ancestorId) {
|
|
12041
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12042
|
+
let id = card.parentIssueId;
|
|
12043
|
+
while (id !== null && !seen.has(id)) {
|
|
12044
|
+
if (id === ancestorId) return true;
|
|
12045
|
+
seen.add(id);
|
|
12046
|
+
id = (await store.issues.findById(id))?.parentIssueId ?? null;
|
|
12047
|
+
}
|
|
12048
|
+
return false;
|
|
12049
|
+
}
|
|
12003
12050
|
/** Is the card's own number already taken by another card in the target workspace? */
|
|
12004
12051
|
async function numberTakenByAnother(store, workspaceId, card) {
|
|
12005
12052
|
const holder = await store.issues.findByNumber(workspaceId, card.number);
|
|
@@ -23776,6 +23823,111 @@ async function openWithPostgresJs(url) {
|
|
|
23776
23823
|
};
|
|
23777
23824
|
}
|
|
23778
23825
|
//#endregion
|
|
23826
|
+
//#region src/cli/project-registry.ts
|
|
23827
|
+
/**
|
|
23828
|
+
* Which project folders kanbo has opened a board in.
|
|
23829
|
+
*
|
|
23830
|
+
* Every project keeps its board in its own folder, so nothing else knows which
|
|
23831
|
+
* other projects exist. This list, in the user's home, is what lets one board
|
|
23832
|
+
* page offer them all. It holds a folder and a time — never a connection
|
|
23833
|
+
* string, never board data — and deleting it is safe: it fills again as the
|
|
23834
|
+
* projects are used.
|
|
23835
|
+
*/
|
|
23836
|
+
/** The file's name inside kanbo's per-user folder. */
|
|
23837
|
+
const REGISTRY_FILE_NAME = "projects.json";
|
|
23838
|
+
/** The file is private to the user, as a binding is. */
|
|
23839
|
+
const REGISTRY_FILE_MODE = 384;
|
|
23840
|
+
/** The folder holding it is private to the user too. */
|
|
23841
|
+
const REGISTRY_DIRECTORY_MODE = 448;
|
|
23842
|
+
/**
|
|
23843
|
+
* A project noted within this many seconds is not noted again, so an ordinary
|
|
23844
|
+
* command does not rewrite the file: a day is fresh enough for "recently used".
|
|
23845
|
+
*/
|
|
23846
|
+
const RECENTLY_NOTED_SECONDS = 86400;
|
|
23847
|
+
const RegisteredProjectSchema = object$1({
|
|
23848
|
+
path: string().min(1),
|
|
23849
|
+
lastSeenAt: number().int().nonnegative()
|
|
23850
|
+
});
|
|
23851
|
+
const RegistrySchema = object$1({
|
|
23852
|
+
schemaVersion: literal(1),
|
|
23853
|
+
projects: array(RegisteredProjectSchema)
|
|
23854
|
+
});
|
|
23855
|
+
/** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
|
|
23856
|
+
function projectRegistryPath(env = process.env) {
|
|
23857
|
+
const home = env.KANBO_HOME?.trim();
|
|
23858
|
+
return (0, node_path.join)(home || (0, node_path.join)((0, node_os.homedir)(), ".kanbo"), REGISTRY_FILE_NAME);
|
|
23859
|
+
}
|
|
23860
|
+
/** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
|
|
23861
|
+
function readProjectRegistry(path = projectRegistryPath()) {
|
|
23862
|
+
return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
23863
|
+
}
|
|
23864
|
+
/**
|
|
23865
|
+
* Note that `projectDir` was just used. Writes only when the folder is new or
|
|
23866
|
+
* was last noted more than a day ago, so ordinary commands do not rewrite the file.
|
|
23867
|
+
* Drops entries whose `.kanbo/binding.json` is gone.
|
|
23868
|
+
*/
|
|
23869
|
+
function rememberProject(projectDir, options = {}) {
|
|
23870
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
23871
|
+
const path = options.path ?? projectRegistryPath();
|
|
23872
|
+
const canonical = canonicalPath$1(projectDir);
|
|
23873
|
+
const entries = readEntries(path);
|
|
23874
|
+
const known = entries.find((entry) => entry.path === canonical);
|
|
23875
|
+
if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
|
|
23876
|
+
const kept = entries.filter((entry) => entry.path !== canonical && (0, node_fs.existsSync)((0, node_path.join)(entry.path, BINDING_FILE_PATH)));
|
|
23877
|
+
kept.push({
|
|
23878
|
+
path: canonical,
|
|
23879
|
+
lastSeenAt: now
|
|
23880
|
+
});
|
|
23881
|
+
writeEntries(path, kept);
|
|
23882
|
+
}
|
|
23883
|
+
/**
|
|
23884
|
+
* `rememberProject`, for the places that must not fail because of it: a home
|
|
23885
|
+
* directory that cannot be written is not a reason for `kanbo card list` to fail.
|
|
23886
|
+
* With `KANBO_DEBUG=1` the reason is printed on stderr.
|
|
23887
|
+
*/
|
|
23888
|
+
function rememberProjectQuietly(projectDir) {
|
|
23889
|
+
const path = projectRegistryPath();
|
|
23890
|
+
try {
|
|
23891
|
+
rememberProject(projectDir, { path });
|
|
23892
|
+
} catch (error) {
|
|
23893
|
+
if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
23894
|
+
}
|
|
23895
|
+
}
|
|
23896
|
+
function canonicalPath$1(projectDir) {
|
|
23897
|
+
const absolute = (0, node_path.resolve)(projectDir);
|
|
23898
|
+
try {
|
|
23899
|
+
return (0, node_fs.realpathSync)(absolute);
|
|
23900
|
+
} catch {
|
|
23901
|
+
return absolute;
|
|
23902
|
+
}
|
|
23903
|
+
}
|
|
23904
|
+
function readEntries(path) {
|
|
23905
|
+
try {
|
|
23906
|
+
const parsed = RegistrySchema.safeParse(JSON.parse((0, node_fs.readFileSync)(path, "utf8")));
|
|
23907
|
+
return parsed.success ? parsed.data.projects : [];
|
|
23908
|
+
} catch {
|
|
23909
|
+
return [];
|
|
23910
|
+
}
|
|
23911
|
+
}
|
|
23912
|
+
function writeEntries(path, projects) {
|
|
23913
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(path), {
|
|
23914
|
+
recursive: true,
|
|
23915
|
+
mode: REGISTRY_DIRECTORY_MODE
|
|
23916
|
+
});
|
|
23917
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
23918
|
+
try {
|
|
23919
|
+
(0, node_fs.writeFileSync)(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
|
|
23920
|
+
schemaVersion: 1,
|
|
23921
|
+
projects
|
|
23922
|
+
}), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
|
|
23923
|
+
(0, node_fs.chmodSync)(temporaryPath, REGISTRY_FILE_MODE);
|
|
23924
|
+
renameWithRetry(temporaryPath, path);
|
|
23925
|
+
} catch (error) {
|
|
23926
|
+
(0, node_fs.rmSync)(temporaryPath, { force: true });
|
|
23927
|
+
throw error;
|
|
23928
|
+
}
|
|
23929
|
+
}
|
|
23930
|
+
//#endregion
|
|
23779
23931
|
//#region src/postgres/open-database.ts
|
|
23780
23932
|
/** `information_schema.tables`, the first of the two views the guard reads. */
|
|
23781
23933
|
const informationSchemaTables = pgSchema("information_schema").table("tables", {
|
|
@@ -24087,15 +24239,17 @@ function withBoardOptions(command) {
|
|
|
24087
24239
|
* as a client is talking to it. A failure on the way out closes the board first
|
|
24088
24240
|
* — a caller that never received a session has nothing to close.
|
|
24089
24241
|
*/
|
|
24090
|
-
async function openBoardSession(options, mode) {
|
|
24091
|
-
const cwd = process.cwd();
|
|
24242
|
+
async function openBoardSession(options, mode, cwd = process.cwd()) {
|
|
24092
24243
|
const target = resolveDbTarget({
|
|
24093
24244
|
explicitPath: options.db,
|
|
24094
24245
|
explicitUrl: options.databaseUrl,
|
|
24095
24246
|
asAgent: options.asAgent,
|
|
24096
24247
|
cwd
|
|
24097
24248
|
});
|
|
24098
|
-
|
|
24249
|
+
const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
|
|
24250
|
+
const found = findBinding(cwd);
|
|
24251
|
+
if (found) rememberProjectQuietly(found.projectDir);
|
|
24252
|
+
return session;
|
|
24099
24253
|
}
|
|
24100
24254
|
async function openFileSession(target, options, mode, cwd) {
|
|
24101
24255
|
const board = await openBoardDatabase(target.path);
|
|
@@ -24705,7 +24859,7 @@ const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
|
24705
24859
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
24706
24860
|
kanbo_card_list: { kanbo: "kanbo card list" },
|
|
24707
24861
|
kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
|
|
24708
|
-
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
|
|
24862
|
+
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
|
|
24709
24863
|
kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
|
|
24710
24864
|
kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
|
|
24711
24865
|
kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
|
|
@@ -24903,6 +25057,81 @@ const cardCommentTool = defineKanboTool({
|
|
|
24903
25057
|
},
|
|
24904
25058
|
run: async (transport, input) => await transport.cardComment(input)
|
|
24905
25059
|
});
|
|
25060
|
+
/** The `detail` and `fields` arguments the list tools share. */
|
|
25061
|
+
const cardListViewArguments = {
|
|
25062
|
+
detail: _enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
|
|
25063
|
+
fields: array(_enum([
|
|
25064
|
+
"number",
|
|
25065
|
+
"title",
|
|
25066
|
+
"description",
|
|
25067
|
+
"column",
|
|
25068
|
+
"columnSlug",
|
|
25069
|
+
"statusLine",
|
|
25070
|
+
"waitingFor",
|
|
25071
|
+
"priority",
|
|
25072
|
+
"labels",
|
|
25073
|
+
"executionMode",
|
|
25074
|
+
"parentIssueId",
|
|
25075
|
+
"attemptCount",
|
|
25076
|
+
"activeRun",
|
|
25077
|
+
"updatedAt"
|
|
25078
|
+
])).optional().describe("Print the id and only these fields of each card, e.g. [\"description\"].")
|
|
25079
|
+
};
|
|
25080
|
+
function compactKanboCard(card) {
|
|
25081
|
+
return {
|
|
25082
|
+
id: card.id,
|
|
25083
|
+
title: cardDisplayTitle(card),
|
|
25084
|
+
column: card.columnSlug,
|
|
25085
|
+
...card.statusLine ? { statusLine: card.statusLine } : {},
|
|
25086
|
+
...card.waitingFor ? { waitingFor: card.waitingFor } : {},
|
|
25087
|
+
...card.parentIssueId ? { parentId: card.parentIssueId } : {},
|
|
25088
|
+
...card.attemptCount > 0 ? { attempt: card.attemptCount } : {},
|
|
25089
|
+
...card.activeRun ? { activeRun: {
|
|
25090
|
+
agentName: card.activeRun.agentName,
|
|
25091
|
+
startedAt: card.activeRun.startedAt
|
|
25092
|
+
} } : {},
|
|
25093
|
+
updatedAt: card.updatedAt,
|
|
25094
|
+
...card.returned ? { returned: card.returned } : {},
|
|
25095
|
+
...card.lastComment ? { lastComment: card.lastComment } : {}
|
|
25096
|
+
};
|
|
25097
|
+
}
|
|
25098
|
+
/** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
|
|
25099
|
+
const writtenCardDetailArgument = _enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
|
|
25100
|
+
/** What each tool that writes a card says about its answer, in its description. */
|
|
25101
|
+
const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
|
|
25102
|
+
/**
|
|
25103
|
+
* The card a write hands back, as the tool answers with it: compact and on one
|
|
25104
|
+
* line, as a list prints it. An agent writes its card at every step — a status
|
|
25105
|
+
* line, a move, an update — and reads each answer again on every later turn,
|
|
25106
|
+
* so a whole card, description included, would cost it again and again.
|
|
25107
|
+
* `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
|
|
25108
|
+
*/
|
|
25109
|
+
function formatWrittenKanboCard(card, detail) {
|
|
25110
|
+
return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
|
|
25111
|
+
}
|
|
25112
|
+
function viewCard(card, view) {
|
|
25113
|
+
if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
|
|
25114
|
+
return view.detail === "full" ? card : compactKanboCard(card);
|
|
25115
|
+
}
|
|
25116
|
+
/**
|
|
25117
|
+
* The page as the tool's answer: how many cards the question picks, where the
|
|
25118
|
+
* next page starts when there is one, and one card per line.
|
|
25119
|
+
*/
|
|
25120
|
+
function formatKanboCardPage(page, view, returned) {
|
|
25121
|
+
const shownUntil = page.offset + page.cards.length;
|
|
25122
|
+
const more = Math.max(0, page.total - shownUntil);
|
|
25123
|
+
const head = {
|
|
25124
|
+
total: page.total,
|
|
25125
|
+
...page.offset > 0 ? { offset: page.offset } : {},
|
|
25126
|
+
...more > 0 ? {
|
|
25127
|
+
more,
|
|
25128
|
+
nextOffset: shownUntil
|
|
25129
|
+
} : {}
|
|
25130
|
+
};
|
|
25131
|
+
const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
|
|
25132
|
+
const opening = `{${returned && returned.cards.length > 0 ? `"returned":[\n${returned.cards.map((card) => JSON.stringify(viewCard(card, view))).join(",\n")}\n],${returned.total > returned.cards.length ? `"returnedTotal":${returned.total},` : ""}` : ""}${JSON.stringify(head).slice(1, -1)}`;
|
|
25133
|
+
return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
|
|
25134
|
+
}
|
|
24906
25135
|
//#endregion
|
|
24907
25136
|
//#region src/mcp/tools/card-create.ts
|
|
24908
25137
|
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
@@ -24911,19 +25140,20 @@ const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pas
|
|
|
24911
25140
|
const cardCreateTool = defineKanboTool({
|
|
24912
25141
|
name: "kanbo_card_create",
|
|
24913
25142
|
title: "Create a card",
|
|
24914
|
-
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}
|
|
25143
|
+
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")} Answers the card compact, as kanbo_card_list prints it; detail: "full" for every field.`,
|
|
24915
25144
|
inputSchema: {
|
|
24916
25145
|
title: string().optional().describe("What the card is called. Leave it out and the card is titled with its own key; the description then says what the task is."),
|
|
24917
25146
|
description: string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
24918
25147
|
column: string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
24919
25148
|
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
24920
|
-
executionMode: executionModeArgument.optional()
|
|
25149
|
+
executionMode: executionModeArgument.optional(),
|
|
25150
|
+
detail: writtenCardDetailArgument
|
|
24921
25151
|
},
|
|
24922
25152
|
example: {
|
|
24923
25153
|
description: "Add slugify(text) with a test",
|
|
24924
25154
|
parent: "TST-1"
|
|
24925
25155
|
},
|
|
24926
|
-
run: async (transport, input) => await transport.cardCreate(input)
|
|
25156
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
|
|
24927
25157
|
});
|
|
24928
25158
|
//#endregion
|
|
24929
25159
|
//#region src/mcp/transport.ts
|
|
@@ -25054,9 +25284,14 @@ function projectKanboColumn(column) {
|
|
|
25054
25284
|
}
|
|
25055
25285
|
/** A comment as a card read lists it, from the row either transport holds. */
|
|
25056
25286
|
function projectKanboCardComment(comment) {
|
|
25287
|
+
const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
|
|
25288
|
+
const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
|
|
25289
|
+
const named = author !== "system" || decision;
|
|
25290
|
+
const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
|
|
25057
25291
|
return {
|
|
25058
25292
|
id: comment.id,
|
|
25059
|
-
author
|
|
25293
|
+
author,
|
|
25294
|
+
by: named || outside ? comment.authorId ?? author : "kanbo",
|
|
25060
25295
|
content: comment.content,
|
|
25061
25296
|
createdAt: comment.createdAt
|
|
25062
25297
|
};
|
|
@@ -25104,67 +25339,6 @@ const cardLinkPrTool = defineKanboTool({
|
|
|
25104
25339
|
},
|
|
25105
25340
|
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
25106
25341
|
});
|
|
25107
|
-
/** The `detail` and `fields` arguments the list tools share. */
|
|
25108
|
-
const cardListViewArguments = {
|
|
25109
|
-
detail: _enum(["compact", "full"]).optional().describe("compact (default): id, title, column slug, status line, waiting, parent, run, updatedAt; returned, and the last comment of a waiting or returned card. full: every field, description included."),
|
|
25110
|
-
fields: array(_enum([
|
|
25111
|
-
"number",
|
|
25112
|
-
"title",
|
|
25113
|
-
"description",
|
|
25114
|
-
"column",
|
|
25115
|
-
"columnSlug",
|
|
25116
|
-
"statusLine",
|
|
25117
|
-
"waitingFor",
|
|
25118
|
-
"priority",
|
|
25119
|
-
"labels",
|
|
25120
|
-
"executionMode",
|
|
25121
|
-
"parentIssueId",
|
|
25122
|
-
"attemptCount",
|
|
25123
|
-
"activeRun",
|
|
25124
|
-
"updatedAt"
|
|
25125
|
-
])).optional().describe("Print the id and only these fields of each card, e.g. [\"description\"].")
|
|
25126
|
-
};
|
|
25127
|
-
function compactKanboCard(card) {
|
|
25128
|
-
return {
|
|
25129
|
-
id: card.id,
|
|
25130
|
-
title: cardDisplayTitle(card),
|
|
25131
|
-
column: card.columnSlug,
|
|
25132
|
-
...card.statusLine ? { statusLine: card.statusLine } : {},
|
|
25133
|
-
...card.waitingFor ? { waitingFor: card.waitingFor } : {},
|
|
25134
|
-
...card.parentIssueId ? { parentId: card.parentIssueId } : {},
|
|
25135
|
-
...card.attemptCount > 0 ? { attempt: card.attemptCount } : {},
|
|
25136
|
-
...card.activeRun ? { activeRun: {
|
|
25137
|
-
agentName: card.activeRun.agentName,
|
|
25138
|
-
startedAt: card.activeRun.startedAt
|
|
25139
|
-
} } : {},
|
|
25140
|
-
updatedAt: card.updatedAt,
|
|
25141
|
-
...card.returned ? { returned: card.returned } : {},
|
|
25142
|
-
...card.lastComment ? { lastComment: card.lastComment } : {}
|
|
25143
|
-
};
|
|
25144
|
-
}
|
|
25145
|
-
function viewCard(card, view) {
|
|
25146
|
-
if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
|
|
25147
|
-
return view.detail === "full" ? card : compactKanboCard(card);
|
|
25148
|
-
}
|
|
25149
|
-
/**
|
|
25150
|
-
* The page as the tool's answer: how many cards the question picks, where the
|
|
25151
|
-
* next page starts when there is one, and one card per line.
|
|
25152
|
-
*/
|
|
25153
|
-
function formatKanboCardPage(page, view, returned) {
|
|
25154
|
-
const shownUntil = page.offset + page.cards.length;
|
|
25155
|
-
const more = Math.max(0, page.total - shownUntil);
|
|
25156
|
-
const head = {
|
|
25157
|
-
total: page.total,
|
|
25158
|
-
...page.offset > 0 ? { offset: page.offset } : {},
|
|
25159
|
-
...more > 0 ? {
|
|
25160
|
-
more,
|
|
25161
|
-
nextOffset: shownUntil
|
|
25162
|
-
} : {}
|
|
25163
|
-
};
|
|
25164
|
-
const cards = page.cards.map((card) => JSON.stringify(viewCard(card, view)));
|
|
25165
|
-
const opening = `{${returned && returned.cards.length > 0 ? `"returned":[\n${returned.cards.map((card) => JSON.stringify(viewCard(card, view))).join(",\n")}\n],${returned.total > returned.cards.length ? `"returnedTotal":${returned.total},` : ""}` : ""}${JSON.stringify(head).slice(1, -1)}`;
|
|
25166
|
-
return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
|
|
25167
|
-
}
|
|
25168
25342
|
/** The cards on the board a question picks, one page of them. */
|
|
25169
25343
|
const cardListTool = defineKanboTool({
|
|
25170
25344
|
name: "kanbo_card_list",
|
|
@@ -25219,10 +25393,11 @@ function readMoment(value) {
|
|
|
25219
25393
|
const cardMoveTool = defineKanboTool({
|
|
25220
25394
|
name: "kanbo_card_move",
|
|
25221
25395
|
title: "Move a card",
|
|
25222
|
-
description:
|
|
25396
|
+
description: `Move a card to another column the moment its real state changes. Read kanbo_columns first: a card in the wrong column is a lie, and a column with entryRules refuses a card that does not meet them yet, listing what is missing. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
25223
25397
|
inputSchema: {
|
|
25224
25398
|
card: cardArgument,
|
|
25225
|
-
column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
|
|
25399
|
+
column: string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
|
|
25400
|
+
detail: writtenCardDetailArgument
|
|
25226
25401
|
},
|
|
25227
25402
|
aliases: { column: ["to"] },
|
|
25228
25403
|
needs: { column: "the column slug, e.g. in_progress" },
|
|
@@ -25230,7 +25405,7 @@ const cardMoveTool = defineKanboTool({
|
|
|
25230
25405
|
card: "TST-5",
|
|
25231
25406
|
column: "in_progress"
|
|
25232
25407
|
},
|
|
25233
|
-
run: async (transport, input) => await transport.cardMove(input)
|
|
25408
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
|
|
25234
25409
|
});
|
|
25235
25410
|
//#endregion
|
|
25236
25411
|
//#region src/mcp/tools/card-pull-requests.ts
|
|
@@ -25245,11 +25420,11 @@ const cardPullRequestsTool = defineKanboTool({
|
|
|
25245
25420
|
});
|
|
25246
25421
|
//#endregion
|
|
25247
25422
|
//#region src/mcp/tools/card-update.ts
|
|
25248
|
-
/** What a card says
|
|
25423
|
+
/** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
|
|
25249
25424
|
const cardUpdateTool = defineKanboTool({
|
|
25250
25425
|
name: "kanbo_card_update",
|
|
25251
25426
|
title: "Update a card",
|
|
25252
|
-
description:
|
|
25427
|
+
description: `Change what a card says: its title, its description, its priority, its labels or where its work happens. A card at the wrong level moves with \`parent\`: another card's id, or "none" for the top level. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
25253
25428
|
inputSchema: {
|
|
25254
25429
|
card: cardArgument,
|
|
25255
25430
|
title: string().min(1).optional().describe("What the card is called."),
|
|
@@ -25262,18 +25437,26 @@ const cardUpdateTool = defineKanboTool({
|
|
|
25262
25437
|
"urgent"
|
|
25263
25438
|
]).optional(),
|
|
25264
25439
|
labels: array(string()).optional().describe("The labels the card carries, replacing the ones it has."),
|
|
25265
|
-
executionMode: executionModeArgument.optional()
|
|
25440
|
+
executionMode: executionModeArgument.optional(),
|
|
25441
|
+
parent: cardArgument.optional().describe("The card to put this one under, by its id or key, e.g. MAN-012; \"none\" makes it a top-level card."),
|
|
25442
|
+
detail: writtenCardDetailArgument
|
|
25266
25443
|
},
|
|
25267
25444
|
example: {
|
|
25268
25445
|
card: "TST-5",
|
|
25269
25446
|
title: "Add slugify"
|
|
25270
25447
|
},
|
|
25271
|
-
|
|
25272
|
-
|
|
25273
|
-
return
|
|
25448
|
+
prepare: (input) => {
|
|
25449
|
+
const { parent, ...rest } = input;
|
|
25450
|
+
return parent === null ? rest : input;
|
|
25451
|
+
},
|
|
25452
|
+
run: async (transport, { card, detail, parent, ...patch }) => {
|
|
25453
|
+
if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
|
|
25454
|
+
const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
|
|
25455
|
+
return formatWrittenKanboCard(await transport.cardUpdate({
|
|
25274
25456
|
card,
|
|
25275
|
-
...patch
|
|
25276
|
-
|
|
25457
|
+
...patch,
|
|
25458
|
+
...placement
|
|
25459
|
+
}), detail);
|
|
25277
25460
|
}
|
|
25278
25461
|
});
|
|
25279
25462
|
//#endregion
|
|
@@ -25410,10 +25593,11 @@ const KANBO_TOOLS = [
|
|
|
25410
25593
|
defineKanboTool({
|
|
25411
25594
|
name: "kanbo_status_line",
|
|
25412
25595
|
title: "Set the status line",
|
|
25413
|
-
description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running.",
|
|
25596
|
+
description: "Say what the card is doing right now: `card` and `text` (one sentence). Write one at every step, including before and after anything long-running. " + WRITTEN_CARD_ANSWER_SENTENCE,
|
|
25414
25597
|
inputSchema: {
|
|
25415
25598
|
card: cardArgument,
|
|
25416
|
-
text: string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
|
|
25599
|
+
text: string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
|
|
25600
|
+
detail: writtenCardDetailArgument
|
|
25417
25601
|
},
|
|
25418
25602
|
aliases: { text: ["content"] },
|
|
25419
25603
|
needs: { text: "the status line, one sentence" },
|
|
@@ -25421,22 +25605,23 @@ const KANBO_TOOLS = [
|
|
|
25421
25605
|
card: "TST-5",
|
|
25422
25606
|
text: "Running the tests"
|
|
25423
25607
|
},
|
|
25424
|
-
run: async (transport, input) => await transport.statusLine(input)
|
|
25608
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
|
|
25425
25609
|
}),
|
|
25426
25610
|
defineKanboTool({
|
|
25427
25611
|
name: "kanbo_wait_approval",
|
|
25428
25612
|
title: "Hand the card to a person",
|
|
25429
|
-
description:
|
|
25613
|
+
description: `Mark the card as waiting for a person and end your turn: \`card\`, and \`text\` for the status line the person reads. A person approves or returns it; you will be told the answer. ${WRITTEN_CARD_ANSWER_SENTENCE}`,
|
|
25430
25614
|
inputSchema: {
|
|
25431
25615
|
card: cardArgument,
|
|
25432
|
-
text: string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`.")
|
|
25616
|
+
text: string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`."),
|
|
25617
|
+
detail: writtenCardDetailArgument
|
|
25433
25618
|
},
|
|
25434
25619
|
aliases: { text: ["content"] },
|
|
25435
25620
|
example: {
|
|
25436
25621
|
card: "TST-5",
|
|
25437
25622
|
text: "Ready for review: slugify added with a test"
|
|
25438
25623
|
},
|
|
25439
|
-
run: async (transport, input) => await transport.waitApproval(input)
|
|
25624
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
|
|
25440
25625
|
}),
|
|
25441
25626
|
runStartTool,
|
|
25442
25627
|
runFinishTool
|
|
@@ -25981,7 +26166,7 @@ function describeCardExtras(extras) {
|
|
|
25981
26166
|
})))].join("\n"));
|
|
25982
26167
|
if (extras.comments && extras.comments.length > 0) {
|
|
25983
26168
|
const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
|
|
25984
|
-
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
26169
|
+
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
25985
26170
|
}
|
|
25986
26171
|
if (extras.runs && extras.runs.length > 0) sections.push(["Runs:", ...extras.runs.map((run) => `#${run.attempt} ${run.state} ${sanitizeTerminalText(run.agentName)} ${formatMoment(run.startedAt)} ${run.id}`)].join("\n"));
|
|
25987
26172
|
if (extras.history && extras.history.length > 0) sections.push(["History:", ...extras.history.map((change) => `${formatMoment(change.createdAt)} ${change.actor} ${change.field}: ${sanitizeTerminalText(change.from ?? "—")} → ${sanitizeTerminalText(change.to ?? "—")}`)].join("\n"));
|
|
@@ -26079,10 +26264,11 @@ function registerCardCommands(program) {
|
|
|
26079
26264
|
}, createPlacementActor()));
|
|
26080
26265
|
});
|
|
26081
26266
|
});
|
|
26082
|
-
withBoardOptions(card.command("update").description("Change a card's title, description, priority or
|
|
26267
|
+
withBoardOptions(card.command("update").description("Change a card's title, description, priority, labels or parent").argument("<card>", "the card, as MAN-012, MAN-12 or 12").option("--title <title>", "what the card is called").option("--description <text>", "what the card is about").option("--priority <priority>", `one of ${PRIORITIES.join(", ")}`, parsePriority).option("--labels <labels>", "comma-separated labels, replacing the ones the card has", parseLabels).option("--execution-mode <mode>", "where the work happens: worktree (its own checkout) or main", parseExecutionMode).option("--parent <card>", "the card to put it under; none puts it on the top level")).action(async (reference, options) => {
|
|
26083
26268
|
await runBoardCommand(options, "write", async (session) => {
|
|
26084
26269
|
const input = readUpdateInput(options);
|
|
26085
26270
|
const existing = await requireCard(session, reference);
|
|
26271
|
+
if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
|
|
26086
26272
|
return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
|
|
26087
26273
|
});
|
|
26088
26274
|
});
|
|
@@ -26223,9 +26409,10 @@ async function presentCard(session, card) {
|
|
|
26223
26409
|
};
|
|
26224
26410
|
}
|
|
26225
26411
|
/**
|
|
26226
|
-
* The fields `card update` was asked to change
|
|
26227
|
-
*
|
|
26228
|
-
*
|
|
26412
|
+
* The fields `card update` was asked to change — all but the parent, a card
|
|
26413
|
+
* of the board, looked up there once the card itself is. A command that names
|
|
26414
|
+
* none of them is refused rather than obeyed: an empty update still bumps the
|
|
26415
|
+
* board version, and every reader would be told the board changed when it did not.
|
|
26229
26416
|
*/
|
|
26230
26417
|
function readUpdateInput(options) {
|
|
26231
26418
|
const input = {};
|
|
@@ -26234,7 +26421,7 @@ function readUpdateInput(options) {
|
|
|
26234
26421
|
if (options.priority !== void 0) input.priority = options.priority;
|
|
26235
26422
|
if (options.labels !== void 0) input.labels = options.labels;
|
|
26236
26423
|
if (options.executionMode !== void 0) input.executionMode = options.executionMode;
|
|
26237
|
-
if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels
|
|
26424
|
+
if (Object.keys(input).length === 0 && options.parent === void 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels, --execution-mode or --parent.");
|
|
26238
26425
|
return input;
|
|
26239
26426
|
}
|
|
26240
26427
|
function parsePriority(value) {
|
|
@@ -42838,6 +43025,7 @@ const COMMAND_SHEET = [
|
|
|
42838
43025
|
"- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
|
|
42839
43026
|
"- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
|
|
42840
43027
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
43028
|
+
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
42841
43029
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
42842
43030
|
"- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
|
|
42843
43031
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
@@ -43965,7 +44153,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
43965
44153
|
setTimeout: globalThis.setTimeout,
|
|
43966
44154
|
clearTimeout: globalThis.clearTimeout
|
|
43967
44155
|
};
|
|
43968
|
-
const current = deps.currentVersion ?? "0.
|
|
44156
|
+
const current = deps.currentVersion ?? "0.4.0";
|
|
43969
44157
|
const controller = new AbortController();
|
|
43970
44158
|
let found = null;
|
|
43971
44159
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -45287,6 +45475,7 @@ async function applyInitPlan(plan) {
|
|
|
45287
45475
|
}
|
|
45288
45476
|
if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
|
|
45289
45477
|
const bindingPath = writeBinding(plan.projectDir, plan.binding);
|
|
45478
|
+
rememberProjectQuietly(plan.projectDir);
|
|
45290
45479
|
const files = plan.fileChanges.map(({ kind, change }) => ({
|
|
45291
45480
|
kind,
|
|
45292
45481
|
...applyFileChange(change)
|
|
@@ -46034,7 +46223,7 @@ async function planAgents(projectDir, agents, options) {
|
|
|
46034
46223
|
}
|
|
46035
46224
|
/** The board this project is bound to, and the workspace on it. */
|
|
46036
46225
|
async function resolveBoard(projectDir, options) {
|
|
46037
|
-
if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
46226
|
+
if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
46038
46227
|
const target = resolveInitTarget(projectDir, options);
|
|
46039
46228
|
if (target.kind === "postgres") return {
|
|
46040
46229
|
target,
|
|
@@ -46053,7 +46242,7 @@ async function resolveBoard(projectDir, options) {
|
|
|
46053
46242
|
* does — a person on a machine with nothing else installed gets a board, not a
|
|
46054
46243
|
* refusal that names a flag.
|
|
46055
46244
|
*/
|
|
46056
|
-
function namesNoBoard(projectDir, options) {
|
|
46245
|
+
function namesNoBoard$1(projectDir, options) {
|
|
46057
46246
|
return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
|
|
46058
46247
|
}
|
|
46059
46248
|
/** The workspace a host database — or the board file this project is already bound to — says this project is. */
|
|
@@ -46486,6 +46675,7 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
46486
46675
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
46487
46676
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
46488
46677
|
`- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
|
|
46678
|
+
`- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
|
|
46489
46679
|
`- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
|
|
46490
46680
|
`- ${PERSON_ONLY_RULE}`,
|
|
46491
46681
|
`- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
|
|
@@ -48193,10 +48383,14 @@ async function createDbTransport(input) {
|
|
|
48193
48383
|
executionMode
|
|
48194
48384
|
}, actor));
|
|
48195
48385
|
},
|
|
48196
|
-
cardUpdate: async ({ card, ...patch }) => {
|
|
48386
|
+
cardUpdate: async ({ card, parent, ...patch }) => {
|
|
48197
48387
|
await session.assertWritable();
|
|
48198
48388
|
const existing = await resolveCard(card);
|
|
48199
|
-
|
|
48389
|
+
const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
|
|
48390
|
+
return await project(await ops.updateCard(existing.id, {
|
|
48391
|
+
...patch,
|
|
48392
|
+
...placement
|
|
48393
|
+
}, actor));
|
|
48200
48394
|
},
|
|
48201
48395
|
cardMove: async ({ card, column }) => {
|
|
48202
48396
|
await session.assertWritable();
|
|
@@ -48350,13 +48544,40 @@ function withUpdateNote(board, peek) {
|
|
|
48350
48544
|
}
|
|
48351
48545
|
//#endregion
|
|
48352
48546
|
//#region src/cli/commands/mcp.ts
|
|
48547
|
+
/**
|
|
48548
|
+
* The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
|
|
48549
|
+
* else the agent tool, else `agent` until the client says its name (see
|
|
48550
|
+
* `nameActorAfterClient`).
|
|
48551
|
+
*/
|
|
48552
|
+
function createMcpActor() {
|
|
48553
|
+
const tool = readAgentTool();
|
|
48554
|
+
const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
|
|
48555
|
+
const actor = createCliActor();
|
|
48556
|
+
if (!namedByShell) actor.id = "agent";
|
|
48557
|
+
return {
|
|
48558
|
+
actor,
|
|
48559
|
+
namedByShell
|
|
48560
|
+
};
|
|
48561
|
+
}
|
|
48562
|
+
/**
|
|
48563
|
+
* Files the session's writes under the client's own name (`Test Client` ->
|
|
48564
|
+
* `test-client`) once it has initialized. `actor` is the object the board
|
|
48565
|
+
* holds, so later writes see the new id.
|
|
48566
|
+
*/
|
|
48567
|
+
function nameActorAfterClient(server, actor) {
|
|
48568
|
+
server.server.oninitialized = () => {
|
|
48569
|
+
const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
|
|
48570
|
+
if (id) actor.id = id;
|
|
48571
|
+
};
|
|
48572
|
+
}
|
|
48353
48573
|
function registerMcpCommand(program) {
|
|
48354
48574
|
program.command("mcp").description("Run the board's MCP server for an agent (it starts this itself)").option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").action(async (options) => {
|
|
48575
|
+
const { actor, namedByShell } = createMcpActor();
|
|
48355
48576
|
const board = await openMcpBoard({
|
|
48356
48577
|
dbPath: options.db,
|
|
48357
48578
|
databaseUrl: options.databaseUrl,
|
|
48358
48579
|
workspaceId: options.workspace,
|
|
48359
|
-
actor
|
|
48580
|
+
actor
|
|
48360
48581
|
});
|
|
48361
48582
|
let closing;
|
|
48362
48583
|
const close = () => closing ??= board.close().catch((error) => {
|
|
@@ -48365,6 +48586,7 @@ function registerMcpCommand(program) {
|
|
|
48365
48586
|
const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
|
|
48366
48587
|
const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
|
|
48367
48588
|
server.server.onclose = () => void close();
|
|
48589
|
+
if (!namedByShell) nameActorAfterClient(server, actor);
|
|
48368
48590
|
try {
|
|
48369
48591
|
await server.connect(new StdioServerTransport());
|
|
48370
48592
|
} catch (error) {
|
|
@@ -49009,6 +49231,10 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
49009
49231
|
status: 404,
|
|
49010
49232
|
message: "Issue not found"
|
|
49011
49233
|
},
|
|
49234
|
+
issue_parent_cycle: {
|
|
49235
|
+
status: 400,
|
|
49236
|
+
message: "Issue cannot be put under one of its own sub-issues"
|
|
49237
|
+
},
|
|
49012
49238
|
issue_parent_not_found: {
|
|
49013
49239
|
status: 404,
|
|
49014
49240
|
message: "Parent issue not found"
|
|
@@ -49390,10 +49616,20 @@ function toStatusView(status) {
|
|
|
49390
49616
|
entryRules: readEntryRules(status)
|
|
49391
49617
|
};
|
|
49392
49618
|
}
|
|
49393
|
-
function toCommentView(comment) {
|
|
49619
|
+
function toCommentView(comment, personWriterId = null) {
|
|
49620
|
+
if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
|
|
49621
|
+
...comment,
|
|
49622
|
+
author: {
|
|
49623
|
+
kind: "user",
|
|
49624
|
+
id: comment.authorId ?? "__self__",
|
|
49625
|
+
displayName: "You",
|
|
49626
|
+
avatarUrl: null,
|
|
49627
|
+
label: null
|
|
49628
|
+
}
|
|
49629
|
+
};
|
|
49394
49630
|
return {
|
|
49395
49631
|
...comment,
|
|
49396
|
-
author: resolveActor(commentAuthorReference(comment))
|
|
49632
|
+
author: resolveActor(commentAuthorReference(comment), personWriterId)
|
|
49397
49633
|
};
|
|
49398
49634
|
}
|
|
49399
49635
|
/**
|
|
@@ -49401,23 +49637,34 @@ function toCommentView(comment) {
|
|
|
49401
49637
|
*
|
|
49402
49638
|
* For the kinds that need nobody else's tables: an outside writer is shown
|
|
49403
49639
|
* under the name it gave, the board writing for itself is `kanbo`, and a person
|
|
49404
|
-
* is `You`.
|
|
49640
|
+
* is `You`. So is an outside writer that is the person who started this
|
|
49641
|
+
* server (`personWriterId`): their own writes carry the name their shell files
|
|
49642
|
+
* them under. An agent and a provider target are rows of whatever app launched
|
|
49405
49643
|
* them, which this server cannot read, so they are shown by the id the board
|
|
49406
49644
|
* holds for them.
|
|
49407
49645
|
*/
|
|
49408
|
-
function resolveActor(actor) {
|
|
49646
|
+
function resolveActor(actor, personWriterId) {
|
|
49409
49647
|
if (actor.kind === "system") {
|
|
49410
49648
|
const provenance = {
|
|
49411
49649
|
authorId: actor.id,
|
|
49412
49650
|
sourceChatSessionId: actor.sourceChatSessionId
|
|
49413
49651
|
};
|
|
49414
|
-
if (isOutsideWriter(provenance))
|
|
49415
|
-
|
|
49416
|
-
|
|
49417
|
-
|
|
49418
|
-
|
|
49419
|
-
|
|
49420
|
-
|
|
49652
|
+
if (isOutsideWriter(provenance)) {
|
|
49653
|
+
if (personWriterId !== null && provenance.authorId === personWriterId) return {
|
|
49654
|
+
kind: "user",
|
|
49655
|
+
id: provenance.authorId,
|
|
49656
|
+
displayName: "You",
|
|
49657
|
+
avatarUrl: null,
|
|
49658
|
+
label: null
|
|
49659
|
+
};
|
|
49660
|
+
return {
|
|
49661
|
+
kind: "system",
|
|
49662
|
+
id: provenance.authorId,
|
|
49663
|
+
displayName: provenance.authorId,
|
|
49664
|
+
avatarUrl: null,
|
|
49665
|
+
label: "External"
|
|
49666
|
+
};
|
|
49667
|
+
}
|
|
49421
49668
|
return {
|
|
49422
49669
|
kind: "system",
|
|
49423
49670
|
id: null,
|
|
@@ -49453,13 +49700,13 @@ function resolveActor(actor) {
|
|
|
49453
49700
|
* serves, named by its identity; actors as `resolveActor` reads them; and an
|
|
49454
49701
|
* agent assignee by its id.
|
|
49455
49702
|
*/
|
|
49456
|
-
function createActivityResolver(workspace) {
|
|
49703
|
+
function createActivityResolver(workspace, personWriterId = null) {
|
|
49457
49704
|
return {
|
|
49458
49705
|
workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
|
|
49459
49706
|
id: workspace.id,
|
|
49460
49707
|
name: workspace.name
|
|
49461
49708
|
}]]),
|
|
49462
|
-
resolveActor: async (actor) => resolveActor(actor),
|
|
49709
|
+
resolveActor: async (actor) => resolveActor(actor, personWriterId),
|
|
49463
49710
|
resolveAgentName: async (agentId) => agentId
|
|
49464
49711
|
};
|
|
49465
49712
|
}
|
|
@@ -49480,10 +49727,15 @@ function createActivityResolver(workspace) {
|
|
|
49480
49727
|
const OK = { ok: true };
|
|
49481
49728
|
/** How many cards a search answers with when the caller names no limit. */
|
|
49482
49729
|
const DEFAULT_SEARCH_LIMIT = 20;
|
|
49483
|
-
|
|
49730
|
+
/**
|
|
49731
|
+
* `personWriterId` is the id the person who started the server is filed under;
|
|
49732
|
+
* their writes read as `You`, everyone else's by name.
|
|
49733
|
+
*/
|
|
49734
|
+
function createIssueRoutes(session, options = {}) {
|
|
49735
|
+
const personWriterId = options.personWriterId ?? null;
|
|
49484
49736
|
const { ops, store, workspace } = session;
|
|
49485
49737
|
const workspaceId = workspace.id;
|
|
49486
|
-
const activityResolver = createActivityResolver(workspace);
|
|
49738
|
+
const activityResolver = createActivityResolver(workspace, personWriterId);
|
|
49487
49739
|
/** The served workspace, when that is the one the caller named — or named none. */
|
|
49488
49740
|
function requireWorkspace(named) {
|
|
49489
49741
|
if (named !== void 0 && named !== workspaceId) throw new BoardError("issue_workspace_not_found", { workspaceId: named });
|
|
@@ -49768,6 +50020,15 @@ function createIssueRoutes(session) {
|
|
|
49768
50020
|
return updated;
|
|
49769
50021
|
}
|
|
49770
50022
|
}),
|
|
50023
|
+
route({
|
|
50024
|
+
method: "GET",
|
|
50025
|
+
path: "/issues/workspace",
|
|
50026
|
+
handle: async () => ({
|
|
50027
|
+
id: workspace.id,
|
|
50028
|
+
identifier: workspace.identifier,
|
|
50029
|
+
name: workspace.name
|
|
50030
|
+
})
|
|
50031
|
+
}),
|
|
49771
50032
|
route({
|
|
49772
50033
|
method: "GET",
|
|
49773
50034
|
path: "/issues/:id",
|
|
@@ -49921,7 +50182,7 @@ function createIssueRoutes(session) {
|
|
|
49921
50182
|
path: "/issues/:id/comments",
|
|
49922
50183
|
handle: async ({ params }) => {
|
|
49923
50184
|
await requireCard(params.id);
|
|
49924
|
-
return (await ops.listComments(params.id)).map(toCommentView);
|
|
50185
|
+
return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
|
|
49925
50186
|
}
|
|
49926
50187
|
}),
|
|
49927
50188
|
route({
|
|
@@ -49934,7 +50195,7 @@ function createIssueRoutes(session) {
|
|
|
49934
50195
|
return toCommentView(await ops.addComment({
|
|
49935
50196
|
issueId: params.id,
|
|
49936
50197
|
content: body.content
|
|
49937
|
-
}, actors.
|
|
50198
|
+
}, actors.person), personWriterId);
|
|
49938
50199
|
}
|
|
49939
50200
|
}),
|
|
49940
50201
|
route({
|
|
@@ -50063,7 +50324,7 @@ function assertServeBinding(host, token) {
|
|
|
50063
50324
|
}
|
|
50064
50325
|
async function startKanboServer(options) {
|
|
50065
50326
|
assertServeBinding(options.host, options.token);
|
|
50066
|
-
const routes = createIssueRoutes(options.session);
|
|
50327
|
+
const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
|
|
50067
50328
|
const listenHost = stripBrackets(options.host);
|
|
50068
50329
|
let boundPort = 0;
|
|
50069
50330
|
const handle = createRequestHandler(routes, options, () => boundPort);
|
|
@@ -50109,6 +50370,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
|
50109
50370
|
"HEAD",
|
|
50110
50371
|
"OPTIONS"
|
|
50111
50372
|
]);
|
|
50373
|
+
/** What the board page tells the server about itself. */
|
|
50374
|
+
const PRESENCE_BODY = object$1({
|
|
50375
|
+
tabId: string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
|
|
50376
|
+
state: _enum(["open", "closed"])
|
|
50377
|
+
});
|
|
50112
50378
|
function createRequestHandler(routes, options, boundPort) {
|
|
50113
50379
|
const token = options.token || null;
|
|
50114
50380
|
const corsOrigins = new Set(options.corsOrigins ?? []);
|
|
@@ -50169,17 +50435,41 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
50169
50435
|
throw new ServeError(401, "unauthorized", "A valid bearer token is required");
|
|
50170
50436
|
}
|
|
50171
50437
|
if (!SAFE_METHODS.has(request.method ?? "GET") && !isJsonContentType(request.headers["content-type"])) throw new ServeError(415, "unsupported_media_type", "Send the request as content-type: application/json");
|
|
50172
|
-
|
|
50173
|
-
|
|
50438
|
+
if (url.pathname === "/page/presence" && request.method === "POST") {
|
|
50439
|
+
if (!options.presence) {
|
|
50440
|
+
response.writeHead(204).end();
|
|
50441
|
+
return;
|
|
50442
|
+
}
|
|
50443
|
+
const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
|
|
50444
|
+
if (!parsed.success) throw validationError("body", parsed.error);
|
|
50445
|
+
if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
|
|
50446
|
+
else options.presence.closed(parsed.data.tabId);
|
|
50447
|
+
sendJson(response, 200, { ok: true });
|
|
50448
|
+
return;
|
|
50449
|
+
}
|
|
50450
|
+
const projectPath = readProjectPath(url.pathname);
|
|
50451
|
+
if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
|
|
50452
|
+
sendJson(response, 200, options.projects.list());
|
|
50453
|
+
return;
|
|
50454
|
+
}
|
|
50455
|
+
const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
|
|
50456
|
+
if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
|
|
50457
|
+
if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
|
|
50174
50458
|
const body = await readJsonBody(request);
|
|
50175
50459
|
const actors = resolveRequestActors(options.actors, {
|
|
50176
50460
|
actorHeader: request.headers[ACTOR_HEADER],
|
|
50177
50461
|
authenticated: token !== null
|
|
50178
50462
|
});
|
|
50179
50463
|
const result = queue.then(async () => {
|
|
50180
|
-
|
|
50181
|
-
|
|
50182
|
-
|
|
50464
|
+
const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
|
|
50465
|
+
session: options.session,
|
|
50466
|
+
routes
|
|
50467
|
+
};
|
|
50468
|
+
const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
|
|
50469
|
+
if (!found) throw new ServeError(404, "not_found", "Not Found");
|
|
50470
|
+
if (found.route.write) await target.session.assertWritable();
|
|
50471
|
+
return await found.route.run({
|
|
50472
|
+
params: found.params,
|
|
50183
50473
|
query: readQuery(url.searchParams),
|
|
50184
50474
|
body,
|
|
50185
50475
|
actors
|
|
@@ -50205,6 +50495,21 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
50205
50495
|
}
|
|
50206
50496
|
};
|
|
50207
50497
|
}
|
|
50498
|
+
/** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
|
|
50499
|
+
function readProjectPath(pathname) {
|
|
50500
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
50501
|
+
if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
|
|
50502
|
+
let projectId;
|
|
50503
|
+
try {
|
|
50504
|
+
projectId = decodeURIComponent(segments[1]);
|
|
50505
|
+
} catch {
|
|
50506
|
+
return null;
|
|
50507
|
+
}
|
|
50508
|
+
return {
|
|
50509
|
+
projectId,
|
|
50510
|
+
rest: `/${segments.slice(2).join("/")}`
|
|
50511
|
+
};
|
|
50512
|
+
}
|
|
50208
50513
|
function isJsonContentType(value) {
|
|
50209
50514
|
return value?.split(";")[0].trim().toLowerCase() === "application/json";
|
|
50210
50515
|
}
|
|
@@ -50348,6 +50653,146 @@ function readTextOrNull(path) {
|
|
|
50348
50653
|
}
|
|
50349
50654
|
}
|
|
50350
50655
|
//#endregion
|
|
50656
|
+
//#region src/serve/page-presence.ts
|
|
50657
|
+
function createPagePresence(options) {
|
|
50658
|
+
const closedGraceMs = options.closedGraceMs ?? 5e3;
|
|
50659
|
+
const staleMs = options.staleMs ?? 12e4;
|
|
50660
|
+
const tickMs = options.tickMs ?? 1e4;
|
|
50661
|
+
const now = options.now ?? Date.now;
|
|
50662
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
50663
|
+
let finished = false;
|
|
50664
|
+
let graceTimer = null;
|
|
50665
|
+
let lastTick = now();
|
|
50666
|
+
function stop() {
|
|
50667
|
+
finished = true;
|
|
50668
|
+
clearInterval(ticker);
|
|
50669
|
+
if (graceTimer) {
|
|
50670
|
+
clearTimeout(graceTimer);
|
|
50671
|
+
graceTimer = null;
|
|
50672
|
+
}
|
|
50673
|
+
}
|
|
50674
|
+
function idle() {
|
|
50675
|
+
if (finished) return;
|
|
50676
|
+
stop();
|
|
50677
|
+
options.onIdle();
|
|
50678
|
+
}
|
|
50679
|
+
const ticker = setInterval(() => {
|
|
50680
|
+
const current = now();
|
|
50681
|
+
const asleep = current - lastTick > 3 * tickMs;
|
|
50682
|
+
lastTick = current;
|
|
50683
|
+
if (asleep) {
|
|
50684
|
+
for (const id of tabs.keys()) tabs.set(id, current);
|
|
50685
|
+
return;
|
|
50686
|
+
}
|
|
50687
|
+
if (tabs.size === 0) return;
|
|
50688
|
+
for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
|
|
50689
|
+
if (tabs.size === 0) idle();
|
|
50690
|
+
}, tickMs);
|
|
50691
|
+
ticker.unref();
|
|
50692
|
+
return {
|
|
50693
|
+
seen: (tabId) => {
|
|
50694
|
+
if (!finished) tabs.set(tabId, now());
|
|
50695
|
+
},
|
|
50696
|
+
closed: (tabId) => {
|
|
50697
|
+
if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
|
|
50698
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
50699
|
+
graceTimer = setTimeout(() => {
|
|
50700
|
+
graceTimer = null;
|
|
50701
|
+
if (tabs.size === 0) idle();
|
|
50702
|
+
}, closedGraceMs);
|
|
50703
|
+
graceTimer.unref();
|
|
50704
|
+
},
|
|
50705
|
+
stop
|
|
50706
|
+
};
|
|
50707
|
+
}
|
|
50708
|
+
//#endregion
|
|
50709
|
+
//#region src/serve/projects.ts
|
|
50710
|
+
function createServeProjects(input) {
|
|
50711
|
+
const routesOptions = { personWriterId: input.personWriterId ?? null };
|
|
50712
|
+
const opened = /* @__PURE__ */ new Map();
|
|
50713
|
+
let defaultOpened = null;
|
|
50714
|
+
/** Every project that can be offered right now, default first, with the folder each lives in. */
|
|
50715
|
+
function entries() {
|
|
50716
|
+
const defaultDir = input.defaultProject?.dir ?? null;
|
|
50717
|
+
const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
|
|
50718
|
+
const found = [];
|
|
50719
|
+
for (const dir of dirs) {
|
|
50720
|
+
const binding = input.readBinding(dir);
|
|
50721
|
+
if (binding === null && dir !== defaultDir) continue;
|
|
50722
|
+
found.push({
|
|
50723
|
+
view: viewOf(dir, binding, dir === defaultDir),
|
|
50724
|
+
dir
|
|
50725
|
+
});
|
|
50726
|
+
}
|
|
50727
|
+
return found;
|
|
50728
|
+
}
|
|
50729
|
+
return {
|
|
50730
|
+
list: () => entries().map((entry) => entry.view),
|
|
50731
|
+
resolve: async (projectId) => {
|
|
50732
|
+
const entry = entries().find((candidate) => candidate.view.id === projectId);
|
|
50733
|
+
if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
|
|
50734
|
+
if (entry.view.isDefault && input.defaultProject) {
|
|
50735
|
+
defaultOpened ??= {
|
|
50736
|
+
session: input.defaultProject.session,
|
|
50737
|
+
routes: createIssueRoutes(input.defaultProject.session, routesOptions)
|
|
50738
|
+
};
|
|
50739
|
+
return defaultOpened;
|
|
50740
|
+
}
|
|
50741
|
+
const cached = opened.get(projectId);
|
|
50742
|
+
if (cached) return cached;
|
|
50743
|
+
let session;
|
|
50744
|
+
try {
|
|
50745
|
+
session = await input.openSession(entry.dir);
|
|
50746
|
+
} catch (error) {
|
|
50747
|
+
throw new ServeError(503, "project_unavailable", describeFailure(error).message);
|
|
50748
|
+
}
|
|
50749
|
+
const project = {
|
|
50750
|
+
session,
|
|
50751
|
+
routes: createIssueRoutes(session, routesOptions)
|
|
50752
|
+
};
|
|
50753
|
+
opened.set(projectId, project);
|
|
50754
|
+
return project;
|
|
50755
|
+
},
|
|
50756
|
+
close: async () => {
|
|
50757
|
+
const sessions = [...opened.values()].map((project) => project.session);
|
|
50758
|
+
opened.clear();
|
|
50759
|
+
await Promise.allSettled(sessions.map(async (session) => await session.close()));
|
|
50760
|
+
}
|
|
50761
|
+
};
|
|
50762
|
+
}
|
|
50763
|
+
function projectId(dir) {
|
|
50764
|
+
return (0, node_crypto.createHash)("sha256").update(dir).digest("hex").slice(0, 12);
|
|
50765
|
+
}
|
|
50766
|
+
function viewOf(dir, binding, isDefault) {
|
|
50767
|
+
return {
|
|
50768
|
+
id: projectId(dir),
|
|
50769
|
+
name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
|
|
50770
|
+
folder: tildify(dir),
|
|
50771
|
+
storage: storageOf(dir, binding),
|
|
50772
|
+
isDefault
|
|
50773
|
+
};
|
|
50774
|
+
}
|
|
50775
|
+
/** Where the board lives. The connection string is read here to name its host, and goes no further. */
|
|
50776
|
+
function storageOf(dir, binding) {
|
|
50777
|
+
if (binding?.databaseUrl) return {
|
|
50778
|
+
kind: "postgres",
|
|
50779
|
+
where: hostOf(maskDatabaseUrl(binding.databaseUrl))
|
|
50780
|
+
};
|
|
50781
|
+
return {
|
|
50782
|
+
kind: "file",
|
|
50783
|
+
path: tildify((0, node_path.resolve)(dir, binding?.dbPath ?? ".kanbo/board.db"))
|
|
50784
|
+
};
|
|
50785
|
+
}
|
|
50786
|
+
/** The host of an already masked connection string; a string that is not one names no host. */
|
|
50787
|
+
function hostOf(maskedUrl) {
|
|
50788
|
+
try {
|
|
50789
|
+
const { host } = new URL(maskedUrl);
|
|
50790
|
+
return host && !host.includes("*") ? host : "a Postgres database";
|
|
50791
|
+
} catch {
|
|
50792
|
+
return "a Postgres database";
|
|
50793
|
+
}
|
|
50794
|
+
}
|
|
50795
|
+
//#endregion
|
|
50351
50796
|
//#region src/cli/commands/serve.ts
|
|
50352
50797
|
function registerServeCommand(program) {
|
|
50353
50798
|
program.command("serve").description("Open the board in your browser").option("--port <port>", "port to listen on", String(DEFAULT_SERVE_PORT)).option("--host <host>", "address to listen on; anything but this computer needs a token", DEFAULT_SERVE_HOST).option("--db <path>", "the board file to open").option("--database-url <url>", "a shared Postgres board to use instead of a board file").option("--workspace <nameOrId>", "the project, when this folder is not bound to one").option("--token <token>", "a password every request must carry (or set KANBO_SERVE_TOKEN); made up for you when left out").option("--cors-origin <origin>", "let pages from this web address call the board; repeat for more", collect, []).option("--no-open", "print the board page link without opening it in the browser").action(async (options) => {
|
|
@@ -50355,7 +50800,7 @@ function registerServeCommand(program) {
|
|
|
50355
50800
|
status: (line) => console.error(line),
|
|
50356
50801
|
page: (line) => console.log(line)
|
|
50357
50802
|
});
|
|
50358
|
-
await
|
|
50803
|
+
if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
|
|
50359
50804
|
try {
|
|
50360
50805
|
await running.close();
|
|
50361
50806
|
process.exit(0);
|
|
@@ -50367,26 +50812,46 @@ function registerServeCommand(program) {
|
|
|
50367
50812
|
}
|
|
50368
50813
|
/**
|
|
50369
50814
|
* Open the board and serve it — everything `kanbo serve` does but wait. The
|
|
50370
|
-
* command waits for Ctrl-C
|
|
50371
|
-
*
|
|
50815
|
+
* command waits for Ctrl-C (or the last board tab closing) and ends the
|
|
50816
|
+
* process; the home menu waits the same and comes back to its menu.
|
|
50372
50817
|
*/
|
|
50373
50818
|
async function startServe(options, output) {
|
|
50374
50819
|
const port = parsePort(options.port);
|
|
50375
50820
|
const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
|
|
50821
|
+
const here = findBinding(process.cwd());
|
|
50822
|
+
const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
|
|
50823
|
+
const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
|
|
50376
50824
|
const session = await openBoardSession({
|
|
50377
50825
|
db: options.db,
|
|
50378
50826
|
databaseUrl: options.databaseUrl,
|
|
50379
50827
|
workspace: options.workspace
|
|
50380
|
-
}, "read");
|
|
50828
|
+
}, "read", mostRecent?.path);
|
|
50381
50829
|
const agentShell = isAgentShell();
|
|
50382
50830
|
const actors = {
|
|
50383
|
-
writer:
|
|
50831
|
+
writer: createServeWriter(),
|
|
50384
50832
|
person: agentShell ? null : createPersonActor()
|
|
50385
50833
|
};
|
|
50834
|
+
const projects = createServeProjects({
|
|
50835
|
+
defaultProject: defaultDir === null ? null : {
|
|
50836
|
+
dir: defaultDir,
|
|
50837
|
+
session
|
|
50838
|
+
},
|
|
50839
|
+
readRegistry: () => readProjectRegistry(),
|
|
50840
|
+
readBinding: (dir) => readProjectBinding(dir),
|
|
50841
|
+
openSession: async (dir) => await openBoardSession({}, "read", dir),
|
|
50842
|
+
personWriterId: actors.writer.id
|
|
50843
|
+
});
|
|
50844
|
+
const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
|
|
50845
|
+
let presence;
|
|
50846
|
+
const idle = new Promise((resolve) => {
|
|
50847
|
+
presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
|
|
50848
|
+
});
|
|
50386
50849
|
let server;
|
|
50387
50850
|
try {
|
|
50388
50851
|
server = await startKanboServer({
|
|
50389
50852
|
session,
|
|
50853
|
+
projects,
|
|
50854
|
+
presence,
|
|
50390
50855
|
actors,
|
|
50391
50856
|
host: options.host,
|
|
50392
50857
|
port,
|
|
@@ -50394,10 +50859,12 @@ async function startServe(options, output) {
|
|
|
50394
50859
|
corsOrigins: options.corsOrigin
|
|
50395
50860
|
});
|
|
50396
50861
|
} catch (error) {
|
|
50862
|
+
presence?.stop();
|
|
50397
50863
|
await session.close();
|
|
50398
50864
|
throw error;
|
|
50399
50865
|
}
|
|
50400
|
-
|
|
50866
|
+
const projectCount = projects.list().length;
|
|
50867
|
+
output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
|
|
50401
50868
|
output.status(`kanbo serve: ${describePersonRights(actors)}`);
|
|
50402
50869
|
presentServePage({
|
|
50403
50870
|
url: server.url,
|
|
@@ -50411,15 +50878,25 @@ async function startServe(options, output) {
|
|
|
50411
50878
|
});
|
|
50412
50879
|
return {
|
|
50413
50880
|
link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
|
|
50881
|
+
idle,
|
|
50414
50882
|
close: async () => {
|
|
50883
|
+
presence?.stop();
|
|
50415
50884
|
try {
|
|
50416
50885
|
await server.close();
|
|
50417
50886
|
} finally {
|
|
50418
|
-
|
|
50887
|
+
try {
|
|
50888
|
+
await projects.close();
|
|
50889
|
+
} finally {
|
|
50890
|
+
await session.close();
|
|
50891
|
+
}
|
|
50419
50892
|
}
|
|
50420
50893
|
}
|
|
50421
50894
|
};
|
|
50422
50895
|
}
|
|
50896
|
+
/** Whether neither a flag nor the environment says which board to open. */
|
|
50897
|
+
function namesNoBoard(options) {
|
|
50898
|
+
return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
|
|
50899
|
+
}
|
|
50423
50900
|
/** What `kanbo serve` with no options is told — the home menu serves the board this way. */
|
|
50424
50901
|
const DEFAULT_SERVE_OPTIONS = {
|
|
50425
50902
|
port: String(DEFAULT_SERVE_PORT),
|
|
@@ -50427,16 +50904,21 @@ const DEFAULT_SERVE_OPTIONS = {
|
|
|
50427
50904
|
corsOrigin: [],
|
|
50428
50905
|
open: true
|
|
50429
50906
|
};
|
|
50430
|
-
/**
|
|
50431
|
-
|
|
50907
|
+
/**
|
|
50908
|
+
* Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
|
|
50909
|
+
* stops listening for both signals whichever it was.
|
|
50910
|
+
*/
|
|
50911
|
+
function waitForStop(idle) {
|
|
50432
50912
|
return new Promise((resolve) => {
|
|
50433
|
-
const
|
|
50434
|
-
process.off("SIGINT",
|
|
50435
|
-
process.off("SIGTERM",
|
|
50436
|
-
resolve();
|
|
50913
|
+
const finish = (why) => {
|
|
50914
|
+
process.off("SIGINT", onSignal);
|
|
50915
|
+
process.off("SIGTERM", onSignal);
|
|
50916
|
+
resolve(why);
|
|
50437
50917
|
};
|
|
50438
|
-
|
|
50439
|
-
process.on("
|
|
50918
|
+
const onSignal = () => finish("interrupt");
|
|
50919
|
+
process.on("SIGINT", onSignal);
|
|
50920
|
+
process.on("SIGTERM", onSignal);
|
|
50921
|
+
idle.then(() => finish("idle"));
|
|
50440
50922
|
});
|
|
50441
50923
|
}
|
|
50442
50924
|
function collect(value, previous) {
|
|
@@ -50720,7 +51202,7 @@ const SERVE_FROM_MENU = {
|
|
|
50720
51202
|
status: (line) => console.error(line),
|
|
50721
51203
|
page: () => {}
|
|
50722
51204
|
}),
|
|
50723
|
-
waitForStop:
|
|
51205
|
+
waitForStop: (running) => waitForStop(running.idle)
|
|
50724
51206
|
};
|
|
50725
51207
|
/** The `--check` instruction cells that mean a kanbo section is there and readable. */
|
|
50726
51208
|
const INSTRUCTION_BLOCK_PRESENT = /* @__PURE__ */ new Set([
|
|
@@ -50882,7 +51364,7 @@ async function serveUntilStopped(context) {
|
|
|
50882
51364
|
const running = await start();
|
|
50883
51365
|
try {
|
|
50884
51366
|
say(context.ui, `Board open at ${running.link} — press Ctrl-C to stop and return to the menu`);
|
|
50885
|
-
await waitForStop();
|
|
51367
|
+
await waitForStop(running);
|
|
50886
51368
|
} finally {
|
|
50887
51369
|
await running.close();
|
|
50888
51370
|
}
|