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
|
@@ -77,22 +77,26 @@ const AGENT_SHELL_MARKERS = [
|
|
|
77
77
|
{
|
|
78
78
|
variable: "CLAUDECODE",
|
|
79
79
|
matches: (value) => value === "1",
|
|
80
|
-
label: "CLAUDECODE=1"
|
|
80
|
+
label: "CLAUDECODE=1",
|
|
81
|
+
tool: "claude"
|
|
81
82
|
},
|
|
82
83
|
{
|
|
83
84
|
variable: "GEMINI_CLI",
|
|
84
85
|
matches: (value) => value === "1",
|
|
85
|
-
label: "GEMINI_CLI=1"
|
|
86
|
+
label: "GEMINI_CLI=1",
|
|
87
|
+
tool: "gemini"
|
|
86
88
|
},
|
|
87
89
|
{
|
|
88
90
|
variable: "CURSOR_AGENT",
|
|
89
91
|
matches: (value) => value !== "",
|
|
90
|
-
label: "CURSOR_AGENT"
|
|
92
|
+
label: "CURSOR_AGENT",
|
|
93
|
+
tool: "cursor"
|
|
91
94
|
},
|
|
92
95
|
{
|
|
93
96
|
variable: "CODEX_THREAD_ID",
|
|
94
97
|
matches: (value) => value !== "",
|
|
95
|
-
label: "CODEX_THREAD_ID"
|
|
98
|
+
label: "CODEX_THREAD_ID",
|
|
99
|
+
tool: "codex"
|
|
96
100
|
}
|
|
97
101
|
];
|
|
98
102
|
/**
|
|
@@ -110,6 +114,15 @@ function readAgentShellMarker(env = process.env) {
|
|
|
110
114
|
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.label ?? null;
|
|
111
115
|
}
|
|
112
116
|
/**
|
|
117
|
+
* The name of the agent tool this shell belongs to (`claude`, `gemini`,
|
|
118
|
+
* `cursor`, `codex`), `agent` when only `KANBO_ACTOR_KIND=agent` says so, or
|
|
119
|
+
* `null` for a person's shell. Same precedence as `readAgentShellMarker`.
|
|
120
|
+
*/
|
|
121
|
+
function readAgentTool(env = process.env) {
|
|
122
|
+
if (readAgentShellMarker(env) === null) return null;
|
|
123
|
+
return AGENT_SHELL_MARKERS.find((candidate) => candidate.matches(env[candidate.variable]?.trim() ?? ""))?.tool ?? "agent";
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
113
126
|
* Every exit code this tool promises, and what it means — the capabilities
|
|
114
127
|
* manifest's `exitCodes` reads this rather than naming the codes again. `0` and
|
|
115
128
|
* `1` are not declared above: they need no name of their own, success and "I
|
|
@@ -305,11 +318,15 @@ const HUMAN_ONLY_MESSAGES = {
|
|
|
305
318
|
"run clear-session": describeRunSessionRefRefusal,
|
|
306
319
|
"run attach-session --replace": describeRunSessionRefRefusal
|
|
307
320
|
};
|
|
308
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* The actor every board write from this tool is filed under: `KANBO_ACTOR_ID`,
|
|
323
|
+
* else the agent tool of an agent's shell (`claude`, `codex`, ...), else the
|
|
324
|
+
* operating system's user.
|
|
325
|
+
*/
|
|
309
326
|
function createCliActor() {
|
|
310
327
|
return {
|
|
311
328
|
kind: "external",
|
|
312
|
-
id: process.env.KANBO_ACTOR_ID?.trim() || safeUsername(),
|
|
329
|
+
id: process.env.KANBO_ACTOR_ID?.trim() || readAgentTool() || safeUsername(),
|
|
313
330
|
name: (0, node_os.hostname)()
|
|
314
331
|
};
|
|
315
332
|
}
|
|
@@ -347,6 +364,14 @@ function createPlacementActor() {
|
|
|
347
364
|
placesForPerson: true
|
|
348
365
|
};
|
|
349
366
|
}
|
|
367
|
+
/** 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. */
|
|
368
|
+
function createServeWriter() {
|
|
369
|
+
return {
|
|
370
|
+
kind: "external",
|
|
371
|
+
id: (isAgentShell() ? "" : process.env.KANBO_ACTOR_ID?.trim() ?? "") || safeUsername(),
|
|
372
|
+
name: (0, node_os.hostname)()
|
|
373
|
+
};
|
|
374
|
+
}
|
|
350
375
|
/**
|
|
351
376
|
* The person who owns this shell, as the board files a person. Only for a
|
|
352
377
|
* caller that has already asked `isAgentShell` — `requireHumanActor` for a
|
|
@@ -2836,12 +2861,33 @@ async function requireParentInWorkspace(store, workspaceId, parentIssueId, issue
|
|
|
2836
2861
|
issueId,
|
|
2837
2862
|
parentIssueId
|
|
2838
2863
|
});
|
|
2839
|
-
|
|
2864
|
+
const parent = await store.issues.findInWorkspace(workspaceId, parentIssueId);
|
|
2865
|
+
if (!parent) throw new require_migrations_path.BoardError("issue_parent_not_found", {
|
|
2840
2866
|
workspaceId,
|
|
2841
2867
|
parentIssueId
|
|
2842
2868
|
});
|
|
2869
|
+
if (issueId !== void 0 && await isUnder(store, parent, issueId)) throw new require_migrations_path.BoardError("issue_parent_cycle", {
|
|
2870
|
+
issueId,
|
|
2871
|
+
parentIssueId
|
|
2872
|
+
});
|
|
2843
2873
|
return parentIssueId;
|
|
2844
2874
|
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Is the card somewhere under `ancestorId` — its parent, its parent's parent,
|
|
2877
|
+
* and so on up? A card put under one of its own sub-cards would make a loop no
|
|
2878
|
+
* reader of the board can walk to the top of. The cards already seen end the
|
|
2879
|
+
* walk, so a loop the data holds from before cannot hang it.
|
|
2880
|
+
*/
|
|
2881
|
+
async function isUnder(store, card, ancestorId) {
|
|
2882
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2883
|
+
let id = card.parentIssueId;
|
|
2884
|
+
while (id !== null && !seen.has(id)) {
|
|
2885
|
+
if (id === ancestorId) return true;
|
|
2886
|
+
seen.add(id);
|
|
2887
|
+
id = (await store.issues.findById(id))?.parentIssueId ?? null;
|
|
2888
|
+
}
|
|
2889
|
+
return false;
|
|
2890
|
+
}
|
|
2845
2891
|
/** Is the card's own number already taken by another card in the target workspace? */
|
|
2846
2892
|
async function numberTakenByAnother(store, workspaceId, card) {
|
|
2847
2893
|
const holder = await store.issues.findByNumber(workspaceId, card.number);
|
|
@@ -3710,62 +3756,6 @@ function createBoardOps(store) {
|
|
|
3710
3756
|
};
|
|
3711
3757
|
}
|
|
3712
3758
|
//#endregion
|
|
3713
|
-
//#region src/domain/database-url.ts
|
|
3714
|
-
/**
|
|
3715
|
-
* A board's connection string, as it may be said out loud.
|
|
3716
|
-
*
|
|
3717
|
-
* Everything a person needs to recognise which board they are talking to is
|
|
3718
|
-
* the host and the database name. The password is the one part that must never
|
|
3719
|
-
* reach a terminal, a log, a `--json` result or a tool's answer — and it is not
|
|
3720
|
-
* the only part: a connection string carries options in its query, and a
|
|
3721
|
-
* `?password=…` there is as much a secret as the one in front of the host.
|
|
3722
|
-
*
|
|
3723
|
-
* It is a fact about the value rather than about the command line, which is why
|
|
3724
|
-
* it lives here: the MCP tools hand back messages they did not write either,
|
|
3725
|
-
* and they must not be made to carry the board tool's own runtime to say them
|
|
3726
|
-
* safely.
|
|
3727
|
-
*/
|
|
3728
|
-
/** What a connection string is called when there is nothing safe to print of it. */
|
|
3729
|
-
const MASKED_UNREADABLE_URL = "postgres://***";
|
|
3730
|
-
/** What is printed in place of a password, and of a query that may hide one. */
|
|
3731
|
-
const MASK = "***";
|
|
3732
|
-
/**
|
|
3733
|
-
* A connection string inside a longer line. Anything but whitespace and the
|
|
3734
|
-
* quotes a message may wrap it in belongs to the URL — a password is allowed
|
|
3735
|
-
* every character a URL is, so the match has to be greedy to be safe.
|
|
3736
|
-
*/
|
|
3737
|
-
const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
3738
|
-
/**
|
|
3739
|
-
* The connection string with everything secret taken out of it.
|
|
3740
|
-
*
|
|
3741
|
-
* A string this cannot take apart is not examined to find out why — it is
|
|
3742
|
-
* replaced entirely, because whatever is wrong with it, the secret is still
|
|
3743
|
-
* somewhere inside. A missing scheme counts as that: `user:secret@host/db`
|
|
3744
|
-
* parses, as a `user:` URL whose whole password is in the path, and printing it
|
|
3745
|
-
* back would say the secret out loud.
|
|
3746
|
-
*/
|
|
3747
|
-
function maskDatabaseUrl(url) {
|
|
3748
|
-
try {
|
|
3749
|
-
const parsed = new URL(url);
|
|
3750
|
-
if (!parsed.host) return MASKED_UNREADABLE_URL;
|
|
3751
|
-
if (parsed.password) parsed.password = MASK;
|
|
3752
|
-
if (parsed.search) parsed.search = MASK;
|
|
3753
|
-
return parsed.toString();
|
|
3754
|
-
} catch {
|
|
3755
|
-
return MASKED_UNREADABLE_URL;
|
|
3756
|
-
}
|
|
3757
|
-
}
|
|
3758
|
-
/**
|
|
3759
|
-
* Every connection string inside a line of text, masked.
|
|
3760
|
-
*
|
|
3761
|
-
* A message this package did not write — a driver's, a migrator's — may carry
|
|
3762
|
-
* the whole connection string in the middle of a sentence, and it reaches the
|
|
3763
|
-
* same terminal, or the same agent, as everything else.
|
|
3764
|
-
*/
|
|
3765
|
-
function maskDatabaseUrls(text) {
|
|
3766
|
-
return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
|
|
3767
|
-
}
|
|
3768
|
-
//#endregion
|
|
3769
3759
|
//#region src/cli/setup/fs-retry.ts
|
|
3770
3760
|
/**
|
|
3771
3761
|
* A file operation tried again while something else holds the file.
|
|
@@ -4045,6 +4035,62 @@ function ignoreBoardFile(projectDir, absoluteDbPath) {
|
|
|
4045
4035
|
(0, node_fs.writeFileSync)(path, kept ? `${kept}\n${missing.join("\n")}\n` : `${missing.join("\n")}\n`);
|
|
4046
4036
|
}
|
|
4047
4037
|
//#endregion
|
|
4038
|
+
//#region src/domain/database-url.ts
|
|
4039
|
+
/**
|
|
4040
|
+
* A board's connection string, as it may be said out loud.
|
|
4041
|
+
*
|
|
4042
|
+
* Everything a person needs to recognise which board they are talking to is
|
|
4043
|
+
* the host and the database name. The password is the one part that must never
|
|
4044
|
+
* reach a terminal, a log, a `--json` result or a tool's answer — and it is not
|
|
4045
|
+
* the only part: a connection string carries options in its query, and a
|
|
4046
|
+
* `?password=…` there is as much a secret as the one in front of the host.
|
|
4047
|
+
*
|
|
4048
|
+
* It is a fact about the value rather than about the command line, which is why
|
|
4049
|
+
* it lives here: the MCP tools hand back messages they did not write either,
|
|
4050
|
+
* and they must not be made to carry the board tool's own runtime to say them
|
|
4051
|
+
* safely.
|
|
4052
|
+
*/
|
|
4053
|
+
/** What a connection string is called when there is nothing safe to print of it. */
|
|
4054
|
+
const MASKED_UNREADABLE_URL = "postgres://***";
|
|
4055
|
+
/** What is printed in place of a password, and of a query that may hide one. */
|
|
4056
|
+
const MASK = "***";
|
|
4057
|
+
/**
|
|
4058
|
+
* A connection string inside a longer line. Anything but whitespace and the
|
|
4059
|
+
* quotes a message may wrap it in belongs to the URL — a password is allowed
|
|
4060
|
+
* every character a URL is, so the match has to be greedy to be safe.
|
|
4061
|
+
*/
|
|
4062
|
+
const DATABASE_URL_PATTERN = /\bpostgres(?:ql)?:\/\/[^\s'"`]+/gi;
|
|
4063
|
+
/**
|
|
4064
|
+
* The connection string with everything secret taken out of it.
|
|
4065
|
+
*
|
|
4066
|
+
* A string this cannot take apart is not examined to find out why — it is
|
|
4067
|
+
* replaced entirely, because whatever is wrong with it, the secret is still
|
|
4068
|
+
* somewhere inside. A missing scheme counts as that: `user:secret@host/db`
|
|
4069
|
+
* parses, as a `user:` URL whose whole password is in the path, and printing it
|
|
4070
|
+
* back would say the secret out loud.
|
|
4071
|
+
*/
|
|
4072
|
+
function maskDatabaseUrl(url) {
|
|
4073
|
+
try {
|
|
4074
|
+
const parsed = new URL(url);
|
|
4075
|
+
if (!parsed.host) return MASKED_UNREADABLE_URL;
|
|
4076
|
+
if (parsed.password) parsed.password = MASK;
|
|
4077
|
+
if (parsed.search) parsed.search = MASK;
|
|
4078
|
+
return parsed.toString();
|
|
4079
|
+
} catch {
|
|
4080
|
+
return MASKED_UNREADABLE_URL;
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
/**
|
|
4084
|
+
* Every connection string inside a line of text, masked.
|
|
4085
|
+
*
|
|
4086
|
+
* A message this package did not write — a driver's, a migrator's — may carry
|
|
4087
|
+
* the whole connection string in the middle of a sentence, and it reaches the
|
|
4088
|
+
* same terminal, or the same agent, as everything else.
|
|
4089
|
+
*/
|
|
4090
|
+
function maskDatabaseUrls(text) {
|
|
4091
|
+
return text.replace(DATABASE_URL_PATTERN, (url) => maskDatabaseUrl(url));
|
|
4092
|
+
}
|
|
4093
|
+
//#endregion
|
|
4048
4094
|
//#region src/cli/db-target.ts
|
|
4049
4095
|
/**
|
|
4050
4096
|
* Which board the command works on.
|
|
@@ -4293,6 +4339,111 @@ async function openWithPostgresJs(url) {
|
|
|
4293
4339
|
};
|
|
4294
4340
|
}
|
|
4295
4341
|
//#endregion
|
|
4342
|
+
//#region src/cli/project-registry.ts
|
|
4343
|
+
/**
|
|
4344
|
+
* Which project folders kanbo has opened a board in.
|
|
4345
|
+
*
|
|
4346
|
+
* Every project keeps its board in its own folder, so nothing else knows which
|
|
4347
|
+
* other projects exist. This list, in the user's home, is what lets one board
|
|
4348
|
+
* page offer them all. It holds a folder and a time — never a connection
|
|
4349
|
+
* string, never board data — and deleting it is safe: it fills again as the
|
|
4350
|
+
* projects are used.
|
|
4351
|
+
*/
|
|
4352
|
+
/** The file's name inside kanbo's per-user folder. */
|
|
4353
|
+
const REGISTRY_FILE_NAME = "projects.json";
|
|
4354
|
+
/** The file is private to the user, as a binding is. */
|
|
4355
|
+
const REGISTRY_FILE_MODE = 384;
|
|
4356
|
+
/** The folder holding it is private to the user too. */
|
|
4357
|
+
const REGISTRY_DIRECTORY_MODE = 448;
|
|
4358
|
+
/**
|
|
4359
|
+
* A project noted within this many seconds is not noted again, so an ordinary
|
|
4360
|
+
* command does not rewrite the file: a day is fresh enough for "recently used".
|
|
4361
|
+
*/
|
|
4362
|
+
const RECENTLY_NOTED_SECONDS = 86400;
|
|
4363
|
+
const RegisteredProjectSchema = zod.z.object({
|
|
4364
|
+
path: zod.z.string().min(1),
|
|
4365
|
+
lastSeenAt: zod.z.number().int().nonnegative()
|
|
4366
|
+
});
|
|
4367
|
+
const RegistrySchema = zod.z.object({
|
|
4368
|
+
schemaVersion: zod.z.literal(1),
|
|
4369
|
+
projects: zod.z.array(RegisteredProjectSchema)
|
|
4370
|
+
});
|
|
4371
|
+
/** `$KANBO_HOME/projects.json`, else `~/.kanbo/projects.json`. */
|
|
4372
|
+
function projectRegistryPath(env = process.env) {
|
|
4373
|
+
const home = env.KANBO_HOME?.trim();
|
|
4374
|
+
return (0, node_path.join)(home || (0, node_path.join)((0, node_os.homedir)(), ".kanbo"), REGISTRY_FILE_NAME);
|
|
4375
|
+
}
|
|
4376
|
+
/** The projects, most recently seen first; `[]` when the file is missing or does not parse. */
|
|
4377
|
+
function readProjectRegistry(path = projectRegistryPath()) {
|
|
4378
|
+
return readEntries(path).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
4379
|
+
}
|
|
4380
|
+
/**
|
|
4381
|
+
* Note that `projectDir` was just used. Writes only when the folder is new or
|
|
4382
|
+
* was last noted more than a day ago, so ordinary commands do not rewrite the file.
|
|
4383
|
+
* Drops entries whose `.kanbo/binding.json` is gone.
|
|
4384
|
+
*/
|
|
4385
|
+
function rememberProject(projectDir, options = {}) {
|
|
4386
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
4387
|
+
const path = options.path ?? projectRegistryPath();
|
|
4388
|
+
const canonical = canonicalPath$1(projectDir);
|
|
4389
|
+
const entries = readEntries(path);
|
|
4390
|
+
const known = entries.find((entry) => entry.path === canonical);
|
|
4391
|
+
if (known && now - known.lastSeenAt <= RECENTLY_NOTED_SECONDS) return;
|
|
4392
|
+
const kept = entries.filter((entry) => entry.path !== canonical && (0, node_fs.existsSync)((0, node_path.join)(entry.path, BINDING_FILE_PATH)));
|
|
4393
|
+
kept.push({
|
|
4394
|
+
path: canonical,
|
|
4395
|
+
lastSeenAt: now
|
|
4396
|
+
});
|
|
4397
|
+
writeEntries(path, kept);
|
|
4398
|
+
}
|
|
4399
|
+
/**
|
|
4400
|
+
* `rememberProject`, for the places that must not fail because of it: a home
|
|
4401
|
+
* directory that cannot be written is not a reason for `kanbo card list` to fail.
|
|
4402
|
+
* With `KANBO_DEBUG=1` the reason is printed on stderr.
|
|
4403
|
+
*/
|
|
4404
|
+
function rememberProjectQuietly(projectDir) {
|
|
4405
|
+
const path = projectRegistryPath();
|
|
4406
|
+
try {
|
|
4407
|
+
rememberProject(projectDir, { path });
|
|
4408
|
+
} catch (error) {
|
|
4409
|
+
if (process.env.KANBO_DEBUG === "1") console.error(`kanbo: could not note this project in ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
function canonicalPath$1(projectDir) {
|
|
4413
|
+
const absolute = (0, node_path.resolve)(projectDir);
|
|
4414
|
+
try {
|
|
4415
|
+
return (0, node_fs.realpathSync)(absolute);
|
|
4416
|
+
} catch {
|
|
4417
|
+
return absolute;
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4420
|
+
function readEntries(path) {
|
|
4421
|
+
try {
|
|
4422
|
+
const parsed = RegistrySchema.safeParse(JSON.parse((0, node_fs.readFileSync)(path, "utf8")));
|
|
4423
|
+
return parsed.success ? parsed.data.projects : [];
|
|
4424
|
+
} catch {
|
|
4425
|
+
return [];
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
function writeEntries(path, projects) {
|
|
4429
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(path), {
|
|
4430
|
+
recursive: true,
|
|
4431
|
+
mode: REGISTRY_DIRECTORY_MODE
|
|
4432
|
+
});
|
|
4433
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
4434
|
+
try {
|
|
4435
|
+
(0, node_fs.writeFileSync)(temporaryPath, `${JSON.stringify(RegistrySchema.parse({
|
|
4436
|
+
schemaVersion: 1,
|
|
4437
|
+
projects
|
|
4438
|
+
}), null, 2)}\n`, { mode: REGISTRY_FILE_MODE });
|
|
4439
|
+
(0, node_fs.chmodSync)(temporaryPath, REGISTRY_FILE_MODE);
|
|
4440
|
+
renameWithRetry(temporaryPath, path);
|
|
4441
|
+
} catch (error) {
|
|
4442
|
+
(0, node_fs.rmSync)(temporaryPath, { force: true });
|
|
4443
|
+
throw error;
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
//#endregion
|
|
4296
4447
|
//#region src/cli/schema-guard.ts
|
|
4297
4448
|
const SCHEMA_OUTDATED_MESSAGE = "The app's database is older than this kanbo. Let the app it belongs to update it first.";
|
|
4298
4449
|
/** The same, for a board file this package created and migrates itself. */
|
|
@@ -4538,15 +4689,17 @@ function withBoardOptions(command) {
|
|
|
4538
4689
|
* as a client is talking to it. A failure on the way out closes the board first
|
|
4539
4690
|
* — a caller that never received a session has nothing to close.
|
|
4540
4691
|
*/
|
|
4541
|
-
async function openBoardSession(options, mode) {
|
|
4542
|
-
const cwd = process.cwd();
|
|
4692
|
+
async function openBoardSession(options, mode, cwd = process.cwd()) {
|
|
4543
4693
|
const target = resolveDbTarget({
|
|
4544
4694
|
explicitPath: options.db,
|
|
4545
4695
|
explicitUrl: options.databaseUrl,
|
|
4546
4696
|
asAgent: options.asAgent,
|
|
4547
4697
|
cwd
|
|
4548
4698
|
});
|
|
4549
|
-
|
|
4699
|
+
const session = target.kind === "postgres" ? await openExternalSession(target, options, cwd) : await openFileSession(target, options, mode, cwd);
|
|
4700
|
+
const found = findBinding(cwd);
|
|
4701
|
+
if (found) rememberProjectQuietly(found.projectDir);
|
|
4702
|
+
return session;
|
|
4550
4703
|
}
|
|
4551
4704
|
async function openFileSession(target, options, mode, cwd) {
|
|
4552
4705
|
const board = await require_migrate.openBoardDatabase(target.path);
|
|
@@ -5431,9 +5584,14 @@ function pageOf(cards, input) {
|
|
|
5431
5584
|
}
|
|
5432
5585
|
/** A comment as a card read lists it, from the row either transport holds. */
|
|
5433
5586
|
function projectKanboCardComment(comment) {
|
|
5587
|
+
const decision = comment.authorKind === "system.approved" || comment.authorKind === "system.returned";
|
|
5588
|
+
const author = decision ? "user" : normalizeCommentAuthorKind(comment.authorKind);
|
|
5589
|
+
const named = author !== "system" || decision;
|
|
5590
|
+
const outside = author === "system" && isOutsideWriter(comment) && comment.authorKind === "system";
|
|
5434
5591
|
return {
|
|
5435
5592
|
id: comment.id,
|
|
5436
|
-
author
|
|
5593
|
+
author,
|
|
5594
|
+
by: named || outside ? comment.authorId ?? author : "kanbo",
|
|
5437
5595
|
content: comment.content,
|
|
5438
5596
|
createdAt: comment.createdAt
|
|
5439
5597
|
};
|
|
@@ -5504,7 +5662,7 @@ function describeCardExtras(extras) {
|
|
|
5504
5662
|
})))].join("\n"));
|
|
5505
5663
|
if (extras.comments && extras.comments.length > 0) {
|
|
5506
5664
|
const heading = extras.commentCount === extras.comments.length ? "Comments:" : `Comments (last ${extras.comments.length} of ${extras.commentCount}):`;
|
|
5507
|
-
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${comment.author}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
5665
|
+
sections.push([heading, ...extras.comments.map((comment) => `${formatMoment(comment.createdAt)} ${sanitizeTerminalText(comment.by)}${comment.author === "user" ? " (person)" : ""}: ${sanitizeTerminalText(comment.content)}`)].join("\n"));
|
|
5508
5666
|
}
|
|
5509
5667
|
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"));
|
|
5510
5668
|
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"));
|
|
@@ -5602,10 +5760,11 @@ function registerCardCommands(program) {
|
|
|
5602
5760
|
}, createPlacementActor()));
|
|
5603
5761
|
});
|
|
5604
5762
|
});
|
|
5605
|
-
withBoardOptions(card.command("update").description("Change a card's title, description, priority or
|
|
5763
|
+
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$1.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) => {
|
|
5606
5764
|
await runBoardCommand(options, "write", async (session) => {
|
|
5607
5765
|
const input = readUpdateInput(options);
|
|
5608
5766
|
const existing = await requireCard(session, reference);
|
|
5767
|
+
if (options.parent !== void 0) input.parentIssueId = options.parent === "none" ? null : (await requireCard(session, options.parent)).id;
|
|
5609
5768
|
return await presentCard(session, await session.ops.updateCard(existing.id, input, createPlacementActor()));
|
|
5610
5769
|
});
|
|
5611
5770
|
});
|
|
@@ -5746,9 +5905,10 @@ async function presentCard(session, card) {
|
|
|
5746
5905
|
};
|
|
5747
5906
|
}
|
|
5748
5907
|
/**
|
|
5749
|
-
* The fields `card update` was asked to change
|
|
5750
|
-
*
|
|
5751
|
-
*
|
|
5908
|
+
* The fields `card update` was asked to change — all but the parent, a card
|
|
5909
|
+
* of the board, looked up there once the card itself is. A command that names
|
|
5910
|
+
* none of them is refused rather than obeyed: an empty update still bumps the
|
|
5911
|
+
* board version, and every reader would be told the board changed when it did not.
|
|
5752
5912
|
*/
|
|
5753
5913
|
function readUpdateInput(options) {
|
|
5754
5914
|
const input = {};
|
|
@@ -5757,7 +5917,7 @@ function readUpdateInput(options) {
|
|
|
5757
5917
|
if (options.priority !== void 0) input.priority = options.priority;
|
|
5758
5918
|
if (options.labels !== void 0) input.labels = options.labels;
|
|
5759
5919
|
if (options.executionMode !== void 0) input.executionMode = options.executionMode;
|
|
5760
|
-
if (Object.keys(input).length === 0) throw new CliError(1, "Nothing to update. Pass --title, --description, --priority, --labels
|
|
5920
|
+
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.");
|
|
5761
5921
|
return input;
|
|
5762
5922
|
}
|
|
5763
5923
|
function parsePriority(value) {
|
|
@@ -6027,6 +6187,7 @@ const BOARD_ERROR_TEXTS = {
|
|
|
6027
6187
|
text: `No card "${named(details.issueId)}" here.`,
|
|
6028
6188
|
next: "kanbo card list"
|
|
6029
6189
|
}),
|
|
6190
|
+
issue_parent_cycle: () => ({ text: "A card can't be put under one of its own sub-cards." }),
|
|
6030
6191
|
issue_parent_not_found: (details) => ({
|
|
6031
6192
|
text: `No card "${named(details.parentIssueId)}" here to put this one under.`,
|
|
6032
6193
|
next: "kanbo card list"
|
|
@@ -6599,7 +6760,7 @@ function readRequestedRules(options) {
|
|
|
6599
6760
|
* `package.version`. A build carries it as a literal; running from source
|
|
6600
6761
|
* (tests, `tsx`) reads the package's own `package.json` instead.
|
|
6601
6762
|
*/
|
|
6602
|
-
const KANBO_PACKAGE_VERSION = "0.
|
|
6763
|
+
const KANBO_PACKAGE_VERSION = "0.4.0";
|
|
6603
6764
|
//#endregion
|
|
6604
6765
|
//#region src/cli/setup/paths.ts
|
|
6605
6766
|
/**
|
|
@@ -6995,6 +7156,7 @@ const COMMAND_SHEET = [
|
|
|
6995
7156
|
"- `kanbo card status-line TST-5 --text \"Running the tests\"` — what is happening on the card right now",
|
|
6996
7157
|
"- `kanbo card comment TST-5 --content \"Added slugify with a test\"` — a finding, result, decision or question",
|
|
6997
7158
|
"- `kanbo card create --description \"...\" --parent TST-5` — a subtask",
|
|
7159
|
+
"- `kanbo card update TST-7 --parent TST-5` — move a card at the wrong level under another (`--parent none`: the top level)",
|
|
6998
7160
|
"- `kanbo card wait-approval TST-5 --text \"Ready for review: ...\"` — ask a person, then end your turn",
|
|
6999
7161
|
"- `kanbo run start TST-5 --agent claude` — record a run you started yourself; it prints the finish command",
|
|
7000
7162
|
"- `kanbo run finish <run id> --state finished` — end it: finished, failed or stopped",
|
|
@@ -8080,7 +8242,7 @@ function startUpdateCheck(deps = {}) {
|
|
|
8080
8242
|
setTimeout: globalThis.setTimeout,
|
|
8081
8243
|
clearTimeout: globalThis.clearTimeout
|
|
8082
8244
|
};
|
|
8083
|
-
const current = deps.currentVersion ?? "0.
|
|
8245
|
+
const current = deps.currentVersion ?? "0.4.0";
|
|
8084
8246
|
const controller = new AbortController();
|
|
8085
8247
|
let found = null;
|
|
8086
8248
|
const timeout = timers.setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
@@ -9317,6 +9479,7 @@ async function applyInitPlan(plan) {
|
|
|
9317
9479
|
}
|
|
9318
9480
|
if (plan.target.kind === "postgres" && plan.migrate) columns = await migrateSharedBoard(plan.target.url, plan.workspace.id, plan.columns);
|
|
9319
9481
|
const bindingPath = writeBinding(plan.projectDir, plan.binding);
|
|
9482
|
+
rememberProjectQuietly(plan.projectDir);
|
|
9320
9483
|
const files = plan.fileChanges.map(({ kind, change }) => ({
|
|
9321
9484
|
kind,
|
|
9322
9485
|
...applyFileChange(change)
|
|
@@ -10064,7 +10227,7 @@ async function planAgents(projectDir, agents, options) {
|
|
|
10064
10227
|
}
|
|
10065
10228
|
/** The board this project is bound to, and the workspace on it. */
|
|
10066
10229
|
async function resolveBoard(projectDir, options) {
|
|
10067
|
-
if (options.file !== void 0 || namesNoBoard(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
10230
|
+
if (options.file !== void 0 || namesNoBoard$1(projectDir, options)) return await resolveOwnFile(projectDir, options);
|
|
10068
10231
|
const target = resolveInitTarget(projectDir, options);
|
|
10069
10232
|
if (target.kind === "postgres") return {
|
|
10070
10233
|
target,
|
|
@@ -10083,7 +10246,7 @@ async function resolveBoard(projectDir, options) {
|
|
|
10083
10246
|
* does — a person on a machine with nothing else installed gets a board, not a
|
|
10084
10247
|
* refusal that names a flag.
|
|
10085
10248
|
*/
|
|
10086
|
-
function namesNoBoard(projectDir, options) {
|
|
10249
|
+
function namesNoBoard$1(projectDir, options) {
|
|
10087
10250
|
return !options.db?.trim() && !options.databaseUrl?.trim() && !process.env.KANBO_DB_PATH?.trim() && !process.env.KANBO_DATABASE_URL?.trim() && findBinding(projectDir) === null;
|
|
10088
10251
|
}
|
|
10089
10252
|
/** The workspace a host database — or the board file this project is already bound to — says this project is. */
|
|
@@ -10516,6 +10679,7 @@ const KANBO_MCP_INSTRUCTIONS = [
|
|
|
10516
10679
|
`- ${STATUS_LINE_RULE} (${tool("kanbo_status_line")})`,
|
|
10517
10680
|
`- ${COMMENT_RULE} (${tool("kanbo_card_comment")})`,
|
|
10518
10681
|
`- ${SUBTASK_RULE} (${tool("kanbo_card_create")} with \`parent\`)`,
|
|
10682
|
+
`- A card at the wrong level is moved with ${tool("kanbo_card_update")} and \`parent\` (\`"none"\` for the top level).`,
|
|
10519
10683
|
`- ${WAIT_FOR_PERSON_RULE} (${tool("kanbo_wait_approval")})`,
|
|
10520
10684
|
`- ${PERSON_ONLY_RULE}`,
|
|
10521
10685
|
`- Every tool, rule and canonical string: the resource \`${KANBO_CAPABILITIES_MARKDOWN_RESOURCE_URI}\`.`
|
|
@@ -10895,61 +11059,6 @@ const cardCommentTool = defineKanboTool({
|
|
|
10895
11059
|
},
|
|
10896
11060
|
run: async (transport, input) => await transport.cardComment(input)
|
|
10897
11061
|
});
|
|
10898
|
-
//#endregion
|
|
10899
|
-
//#region src/mcp/tools/card-create.ts
|
|
10900
|
-
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
10901
|
-
const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
|
|
10902
|
-
/** A new card, or a sub-card of the one being worked on. */
|
|
10903
|
-
const cardCreateTool = defineKanboTool({
|
|
10904
|
-
name: "kanbo_card_create",
|
|
10905
|
-
title: "Create a card",
|
|
10906
|
-
description: `Put a new card on this board. ${SUBTASK_SENTENCE} ${SUBTASK_WHEN_SENTENCE} ${subtaskSplitSentence("pass `parent`")}`,
|
|
10907
|
-
inputSchema: {
|
|
10908
|
-
title: zod.z.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."),
|
|
10909
|
-
description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
10910
|
-
column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
10911
|
-
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
10912
|
-
executionMode: executionModeArgument.optional()
|
|
10913
|
-
},
|
|
10914
|
-
example: {
|
|
10915
|
-
description: "Add slugify(text) with a test",
|
|
10916
|
-
parent: "TST-1"
|
|
10917
|
-
},
|
|
10918
|
-
run: async (transport, input) => await transport.cardCreate(input)
|
|
10919
|
-
});
|
|
10920
|
-
//#endregion
|
|
10921
|
-
//#region src/mcp/tools/card-get.ts
|
|
10922
|
-
/** One card in full, with what an agent reads next to it. */
|
|
10923
|
-
const cardGetTool = defineKanboTool({
|
|
10924
|
-
name: "kanbo_card_get",
|
|
10925
|
-
title: "Read a card",
|
|
10926
|
-
description: `Read one card in full — description, column, status line, whose turn it is, parent (\`parentIssueId\`) — with its last 10 comments and its subtasks (\`subCards\`), in one call. Its runs, field history and pull requests too: include: ["comments","subCards","runs","history","prs"].`,
|
|
10927
|
-
inputSchema: {
|
|
10928
|
-
card: cardArgument,
|
|
10929
|
-
include: zod.z.array(zod.z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
|
|
10930
|
-
commentLimit: zod.z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
|
|
10931
|
-
},
|
|
10932
|
-
example: { card: "TST-5" },
|
|
10933
|
-
run: async (transport, input) => await transport.cardGet(input)
|
|
10934
|
-
});
|
|
10935
|
-
//#endregion
|
|
10936
|
-
//#region src/mcp/tools/card-link-pr.ts
|
|
10937
|
-
/** A pull request the card's work went into, named on the card so its facts reach it. */
|
|
10938
|
-
const cardLinkPrTool = defineKanboTool({
|
|
10939
|
-
name: "kanbo_card_link_pr",
|
|
10940
|
-
title: "Link a pull request to a card",
|
|
10941
|
-
description: "Opened a pull request for this card? Link it here. Its state and CI results then reach the card as comments, without you writing them. Linking the same pull request again changes nothing.",
|
|
10942
|
-
inputSchema: {
|
|
10943
|
-
card: cardArgument,
|
|
10944
|
-
url: zod.z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
|
|
10945
|
-
},
|
|
10946
|
-
needs: { url: "the pull request" },
|
|
10947
|
-
example: {
|
|
10948
|
-
card: "TST-5",
|
|
10949
|
-
url: "https://github.com/owner/repo/pull/12"
|
|
10950
|
-
},
|
|
10951
|
-
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
10952
|
-
});
|
|
10953
11062
|
/** The `detail` and `fields` arguments the list tools share. */
|
|
10954
11063
|
const cardListViewArguments = {
|
|
10955
11064
|
detail: zod.z.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."),
|
|
@@ -10988,6 +11097,20 @@ function compactKanboCard(card) {
|
|
|
10988
11097
|
...card.lastComment ? { lastComment: card.lastComment } : {}
|
|
10989
11098
|
};
|
|
10990
11099
|
}
|
|
11100
|
+
/** The `detail` argument of the tools that write a card: a list's own word, for the one card a write answers with. */
|
|
11101
|
+
const writtenCardDetailArgument = zod.z.enum(["compact", "full"]).optional().describe("compact (default): the card as kanbo_card_list prints it. full: every field, description included.");
|
|
11102
|
+
/** What each tool that writes a card says about its answer, in its description. */
|
|
11103
|
+
const WRITTEN_CARD_ANSWER_SENTENCE = "Answers the card compact, as kanbo_card_list prints it; detail: \"full\" for every field.";
|
|
11104
|
+
/**
|
|
11105
|
+
* The card a write hands back, as the tool answers with it: compact and on one
|
|
11106
|
+
* line, as a list prints it. An agent writes its card at every step — a status
|
|
11107
|
+
* line, a move, an update — and reads each answer again on every later turn,
|
|
11108
|
+
* so a whole card, description included, would cost it again and again.
|
|
11109
|
+
* `detail: "full"` answers every field, as `kanbo_card_get` prints the card alone.
|
|
11110
|
+
*/
|
|
11111
|
+
function formatWrittenKanboCard(card, detail) {
|
|
11112
|
+
return detail === "full" ? JSON.stringify(card, null, 2) : JSON.stringify(compactKanboCard(card));
|
|
11113
|
+
}
|
|
10991
11114
|
function viewCard(card, view) {
|
|
10992
11115
|
if (view.fields) return Object.fromEntries([["id", card.id], ...view.fields.map((field) => [field, card[field]])]);
|
|
10993
11116
|
return view.detail === "full" ? card : compactKanboCard(card);
|
|
@@ -11011,6 +11134,62 @@ function formatKanboCardPage(page, view, returned) {
|
|
|
11011
11134
|
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)}`;
|
|
11012
11135
|
return cards.length === 0 ? `${opening},"cards":[]}` : `${opening},"cards":[\n${cards.join(",\n")}\n]}`;
|
|
11013
11136
|
}
|
|
11137
|
+
//#endregion
|
|
11138
|
+
//#region src/mcp/tools/card-create.ts
|
|
11139
|
+
/** What an agent is told about subtasks, word for word in the tool and in its `parent` field. */
|
|
11140
|
+
const SUBTASK_SENTENCE = "To create a subtask (sub-card) under another card, pass `parent` = that card's id (its key, e.g. `MAN-012`).";
|
|
11141
|
+
/** A new card, or a sub-card of the one being worked on. */
|
|
11142
|
+
const cardCreateTool = defineKanboTool({
|
|
11143
|
+
name: "kanbo_card_create",
|
|
11144
|
+
title: "Create a card",
|
|
11145
|
+
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.`,
|
|
11146
|
+
inputSchema: {
|
|
11147
|
+
title: zod.z.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."),
|
|
11148
|
+
description: zod.z.string().optional().describe("What the card is about, spelled out for whoever picks it up."),
|
|
11149
|
+
column: zod.z.string().min(1).optional().describe("The column to open it in, by slug or name. The board's first column when absent."),
|
|
11150
|
+
parent: cardArgument.optional().describe(SUBTASK_SENTENCE),
|
|
11151
|
+
executionMode: executionModeArgument.optional(),
|
|
11152
|
+
detail: writtenCardDetailArgument
|
|
11153
|
+
},
|
|
11154
|
+
example: {
|
|
11155
|
+
description: "Add slugify(text) with a test",
|
|
11156
|
+
parent: "TST-1"
|
|
11157
|
+
},
|
|
11158
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardCreate(input), detail)
|
|
11159
|
+
});
|
|
11160
|
+
//#endregion
|
|
11161
|
+
//#region src/mcp/tools/card-get.ts
|
|
11162
|
+
/** One card in full, with what an agent reads next to it. */
|
|
11163
|
+
const cardGetTool = defineKanboTool({
|
|
11164
|
+
name: "kanbo_card_get",
|
|
11165
|
+
title: "Read a card",
|
|
11166
|
+
description: `Read one card in full — description, column, status line, whose turn it is, parent (\`parentIssueId\`) — with its last 10 comments and its subtasks (\`subCards\`), in one call. Its runs, field history and pull requests too: include: ["comments","subCards","runs","history","prs"].`,
|
|
11167
|
+
inputSchema: {
|
|
11168
|
+
card: cardArgument,
|
|
11169
|
+
include: zod.z.array(zod.z.enum(KANBO_CARD_INCLUDES)).optional().describe("What to read with the card; [\"comments\",\"subCards\"] when absent, [] for the card alone."),
|
|
11170
|
+
commentLimit: zod.z.number().int().positive().optional().describe(`How many of the latest comments to include; 10 when absent.`)
|
|
11171
|
+
},
|
|
11172
|
+
example: { card: "TST-5" },
|
|
11173
|
+
run: async (transport, input) => await transport.cardGet(input)
|
|
11174
|
+
});
|
|
11175
|
+
//#endregion
|
|
11176
|
+
//#region src/mcp/tools/card-link-pr.ts
|
|
11177
|
+
/** A pull request the card's work went into, named on the card so its facts reach it. */
|
|
11178
|
+
const cardLinkPrTool = defineKanboTool({
|
|
11179
|
+
name: "kanbo_card_link_pr",
|
|
11180
|
+
title: "Link a pull request to a card",
|
|
11181
|
+
description: "Opened a pull request for this card? Link it here. Its state and CI results then reach the card as comments, without you writing them. Linking the same pull request again changes nothing.",
|
|
11182
|
+
inputSchema: {
|
|
11183
|
+
card: cardArgument,
|
|
11184
|
+
url: zod.z.string().min(1).describe("The pull request, as https://github.com/<owner>/<repo>/pull/<number> or <owner>/<repo>#<number>.")
|
|
11185
|
+
},
|
|
11186
|
+
needs: { url: "the pull request" },
|
|
11187
|
+
example: {
|
|
11188
|
+
card: "TST-5",
|
|
11189
|
+
url: "https://github.com/owner/repo/pull/12"
|
|
11190
|
+
},
|
|
11191
|
+
run: async (transport, input) => await transport.cardLinkPullRequest(input)
|
|
11192
|
+
});
|
|
11014
11193
|
/** The cards on the board a question picks, one page of them. */
|
|
11015
11194
|
const cardListTool = defineKanboTool({
|
|
11016
11195
|
name: "kanbo_card_list",
|
|
@@ -11065,10 +11244,11 @@ function readMoment(value) {
|
|
|
11065
11244
|
const cardMoveTool = defineKanboTool({
|
|
11066
11245
|
name: "kanbo_card_move",
|
|
11067
11246
|
title: "Move a card",
|
|
11068
|
-
description:
|
|
11247
|
+
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}`,
|
|
11069
11248
|
inputSchema: {
|
|
11070
11249
|
card: cardArgument,
|
|
11071
|
-
column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`.")
|
|
11250
|
+
column: zod.z.string().min(1).describe("The column to move it to, by its slug or name: in_progress, \"In Progress\". Also accepted as `to`."),
|
|
11251
|
+
detail: writtenCardDetailArgument
|
|
11072
11252
|
},
|
|
11073
11253
|
aliases: { column: ["to"] },
|
|
11074
11254
|
needs: { column: "the column slug, e.g. in_progress" },
|
|
@@ -11076,7 +11256,7 @@ const cardMoveTool = defineKanboTool({
|
|
|
11076
11256
|
card: "TST-5",
|
|
11077
11257
|
column: "in_progress"
|
|
11078
11258
|
},
|
|
11079
|
-
run: async (transport, input) => await transport.cardMove(input)
|
|
11259
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.cardMove(input), detail)
|
|
11080
11260
|
});
|
|
11081
11261
|
//#endregion
|
|
11082
11262
|
//#region src/mcp/tools/card-pull-requests.ts
|
|
@@ -11091,11 +11271,11 @@ const cardPullRequestsTool = defineKanboTool({
|
|
|
11091
11271
|
});
|
|
11092
11272
|
//#endregion
|
|
11093
11273
|
//#region src/mcp/tools/card-update.ts
|
|
11094
|
-
/** What a card says
|
|
11274
|
+
/** What a card says — its title, what it is about, how urgent it is, how it is labelled — and which card it sits under. */
|
|
11095
11275
|
const cardUpdateTool = defineKanboTool({
|
|
11096
11276
|
name: "kanbo_card_update",
|
|
11097
11277
|
title: "Update a card",
|
|
11098
|
-
description:
|
|
11278
|
+
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}`,
|
|
11099
11279
|
inputSchema: {
|
|
11100
11280
|
card: cardArgument,
|
|
11101
11281
|
title: zod.z.string().min(1).optional().describe("What the card is called."),
|
|
@@ -11108,18 +11288,26 @@ const cardUpdateTool = defineKanboTool({
|
|
|
11108
11288
|
"urgent"
|
|
11109
11289
|
]).optional(),
|
|
11110
11290
|
labels: zod.z.array(zod.z.string()).optional().describe("The labels the card carries, replacing the ones it has."),
|
|
11111
|
-
executionMode: executionModeArgument.optional()
|
|
11291
|
+
executionMode: executionModeArgument.optional(),
|
|
11292
|
+
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."),
|
|
11293
|
+
detail: writtenCardDetailArgument
|
|
11112
11294
|
},
|
|
11113
11295
|
example: {
|
|
11114
11296
|
card: "TST-5",
|
|
11115
11297
|
title: "Add slugify"
|
|
11116
11298
|
},
|
|
11117
|
-
|
|
11118
|
-
|
|
11119
|
-
return
|
|
11299
|
+
prepare: (input) => {
|
|
11300
|
+
const { parent, ...rest } = input;
|
|
11301
|
+
return parent === null ? rest : input;
|
|
11302
|
+
},
|
|
11303
|
+
run: async (transport, { card, detail, parent, ...patch }) => {
|
|
11304
|
+
if (Object.keys(patch).length === 0 && parent === void 0) throw new Error("Nothing to update. Pass title, description, priority, labels, executionMode or parent.");
|
|
11305
|
+
const placement = parent === void 0 ? {} : { parent: parent === "none" ? null : parent };
|
|
11306
|
+
return formatWrittenKanboCard(await transport.cardUpdate({
|
|
11120
11307
|
card,
|
|
11121
|
-
...patch
|
|
11122
|
-
|
|
11308
|
+
...patch,
|
|
11309
|
+
...placement
|
|
11310
|
+
}), detail);
|
|
11123
11311
|
}
|
|
11124
11312
|
});
|
|
11125
11313
|
//#endregion
|
|
@@ -11256,10 +11444,11 @@ const KANBO_TOOLS = [
|
|
|
11256
11444
|
defineKanboTool({
|
|
11257
11445
|
name: "kanbo_status_line",
|
|
11258
11446
|
title: "Set the status line",
|
|
11259
|
-
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.",
|
|
11447
|
+
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,
|
|
11260
11448
|
inputSchema: {
|
|
11261
11449
|
card: cardArgument,
|
|
11262
|
-
text: zod.z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`.")
|
|
11450
|
+
text: zod.z.string().min(1).describe("One sentence, present tense, about what is happening on the card right now. Also accepted as `content`."),
|
|
11451
|
+
detail: writtenCardDetailArgument
|
|
11263
11452
|
},
|
|
11264
11453
|
aliases: { text: ["content"] },
|
|
11265
11454
|
needs: { text: "the status line, one sentence" },
|
|
@@ -11267,22 +11456,23 @@ const KANBO_TOOLS = [
|
|
|
11267
11456
|
card: "TST-5",
|
|
11268
11457
|
text: "Running the tests"
|
|
11269
11458
|
},
|
|
11270
|
-
run: async (transport, input) => await transport.statusLine(input)
|
|
11459
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.statusLine(input), detail)
|
|
11271
11460
|
}),
|
|
11272
11461
|
defineKanboTool({
|
|
11273
11462
|
name: "kanbo_wait_approval",
|
|
11274
11463
|
title: "Hand the card to a person",
|
|
11275
|
-
description:
|
|
11464
|
+
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}`,
|
|
11276
11465
|
inputSchema: {
|
|
11277
11466
|
card: cardArgument,
|
|
11278
|
-
text: zod.z.string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`.")
|
|
11467
|
+
text: zod.z.string().min(1).optional().describe("The status line to leave, saying what the person is being asked to look at. Also accepted as `content`."),
|
|
11468
|
+
detail: writtenCardDetailArgument
|
|
11279
11469
|
},
|
|
11280
11470
|
aliases: { text: ["content"] },
|
|
11281
11471
|
example: {
|
|
11282
11472
|
card: "TST-5",
|
|
11283
11473
|
text: "Ready for review: slugify added with a test"
|
|
11284
11474
|
},
|
|
11285
|
-
run: async (transport, input) => await transport.waitApproval(input)
|
|
11475
|
+
run: async (transport, { detail, ...input }) => formatWrittenKanboCard(await transport.waitApproval(input), detail)
|
|
11286
11476
|
}),
|
|
11287
11477
|
runStartTool,
|
|
11288
11478
|
runFinishTool
|
|
@@ -11483,10 +11673,14 @@ async function createDbTransport(input) {
|
|
|
11483
11673
|
executionMode
|
|
11484
11674
|
}, actor));
|
|
11485
11675
|
},
|
|
11486
|
-
cardUpdate: async ({ card, ...patch }) => {
|
|
11676
|
+
cardUpdate: async ({ card, parent, ...patch }) => {
|
|
11487
11677
|
await session.assertWritable();
|
|
11488
11678
|
const existing = await resolveCard(card);
|
|
11489
|
-
|
|
11679
|
+
const placement = parent === void 0 ? {} : { parentIssueId: parent === null ? null : (await resolveCard(parent)).id };
|
|
11680
|
+
return await project(await ops.updateCard(existing.id, {
|
|
11681
|
+
...patch,
|
|
11682
|
+
...placement
|
|
11683
|
+
}, actor));
|
|
11490
11684
|
},
|
|
11491
11685
|
cardMove: async ({ card, column }) => {
|
|
11492
11686
|
await session.assertWritable();
|
|
@@ -11640,13 +11834,40 @@ function withUpdateNote(board, peek) {
|
|
|
11640
11834
|
}
|
|
11641
11835
|
//#endregion
|
|
11642
11836
|
//#region src/cli/commands/mcp.ts
|
|
11837
|
+
/**
|
|
11838
|
+
* The actor of a `kanbo mcp` session, which is always an agent's: `KANBO_ACTOR_ID`,
|
|
11839
|
+
* else the agent tool, else `agent` until the client says its name (see
|
|
11840
|
+
* `nameActorAfterClient`).
|
|
11841
|
+
*/
|
|
11842
|
+
function createMcpActor() {
|
|
11843
|
+
const tool = readAgentTool();
|
|
11844
|
+
const namedByShell = Boolean(process.env.KANBO_ACTOR_ID?.trim() || tool && tool !== "agent");
|
|
11845
|
+
const actor = createCliActor();
|
|
11846
|
+
if (!namedByShell) actor.id = "agent";
|
|
11847
|
+
return {
|
|
11848
|
+
actor,
|
|
11849
|
+
namedByShell
|
|
11850
|
+
};
|
|
11851
|
+
}
|
|
11852
|
+
/**
|
|
11853
|
+
* Files the session's writes under the client's own name (`Test Client` ->
|
|
11854
|
+
* `test-client`) once it has initialized. `actor` is the object the board
|
|
11855
|
+
* holds, so later writes see the new id.
|
|
11856
|
+
*/
|
|
11857
|
+
function nameActorAfterClient(server, actor) {
|
|
11858
|
+
server.server.oninitialized = () => {
|
|
11859
|
+
const id = (server.server.getClientVersion()?.name)?.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 40);
|
|
11860
|
+
if (id) actor.id = id;
|
|
11861
|
+
};
|
|
11862
|
+
}
|
|
11643
11863
|
function registerMcpCommand(program) {
|
|
11644
11864
|
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) => {
|
|
11865
|
+
const { actor, namedByShell } = createMcpActor();
|
|
11645
11866
|
const board = await openMcpBoard({
|
|
11646
11867
|
dbPath: options.db,
|
|
11647
11868
|
databaseUrl: options.databaseUrl,
|
|
11648
11869
|
workspaceId: options.workspace,
|
|
11649
|
-
actor
|
|
11870
|
+
actor
|
|
11650
11871
|
});
|
|
11651
11872
|
let closing;
|
|
11652
11873
|
const close = () => closing ??= board.close().catch((error) => {
|
|
@@ -11655,6 +11876,7 @@ function registerMcpCommand(program) {
|
|
|
11655
11876
|
const check = isUpdateCheckDisabled() ? null : startUpdateCheck();
|
|
11656
11877
|
const server = createKanboMcpServer(check ? withUpdateNote(board, check.peek) : board, { includeRunTools: true });
|
|
11657
11878
|
server.server.onclose = () => void close();
|
|
11879
|
+
if (!namedByShell) nameActorAfterClient(server, actor);
|
|
11658
11880
|
try {
|
|
11659
11881
|
await server.connect(new _modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport());
|
|
11660
11882
|
} catch (error) {
|
|
@@ -12299,6 +12521,10 @@ const BOARD_ERROR_RESPONSES = {
|
|
|
12299
12521
|
status: 404,
|
|
12300
12522
|
message: "Issue not found"
|
|
12301
12523
|
},
|
|
12524
|
+
issue_parent_cycle: {
|
|
12525
|
+
status: 400,
|
|
12526
|
+
message: "Issue cannot be put under one of its own sub-issues"
|
|
12527
|
+
},
|
|
12302
12528
|
issue_parent_not_found: {
|
|
12303
12529
|
status: 404,
|
|
12304
12530
|
message: "Parent issue not found"
|
|
@@ -12680,10 +12906,20 @@ function toStatusView(status) {
|
|
|
12680
12906
|
entryRules: readEntryRules(status)
|
|
12681
12907
|
};
|
|
12682
12908
|
}
|
|
12683
|
-
function toCommentView(comment) {
|
|
12909
|
+
function toCommentView(comment, personWriterId = null) {
|
|
12910
|
+
if (comment.authorKind === "system.approved" || comment.authorKind === "system.returned") return {
|
|
12911
|
+
...comment,
|
|
12912
|
+
author: {
|
|
12913
|
+
kind: "user",
|
|
12914
|
+
id: comment.authorId ?? "__self__",
|
|
12915
|
+
displayName: "You",
|
|
12916
|
+
avatarUrl: null,
|
|
12917
|
+
label: null
|
|
12918
|
+
}
|
|
12919
|
+
};
|
|
12684
12920
|
return {
|
|
12685
12921
|
...comment,
|
|
12686
|
-
author: resolveActor(commentAuthorReference(comment))
|
|
12922
|
+
author: resolveActor(commentAuthorReference(comment), personWriterId)
|
|
12687
12923
|
};
|
|
12688
12924
|
}
|
|
12689
12925
|
/**
|
|
@@ -12691,23 +12927,34 @@ function toCommentView(comment) {
|
|
|
12691
12927
|
*
|
|
12692
12928
|
* For the kinds that need nobody else's tables: an outside writer is shown
|
|
12693
12929
|
* under the name it gave, the board writing for itself is `kanbo`, and a person
|
|
12694
|
-
* is `You`.
|
|
12930
|
+
* is `You`. So is an outside writer that is the person who started this
|
|
12931
|
+
* server (`personWriterId`): their own writes carry the name their shell files
|
|
12932
|
+
* them under. An agent and a provider target are rows of whatever app launched
|
|
12695
12933
|
* them, which this server cannot read, so they are shown by the id the board
|
|
12696
12934
|
* holds for them.
|
|
12697
12935
|
*/
|
|
12698
|
-
function resolveActor(actor) {
|
|
12936
|
+
function resolveActor(actor, personWriterId) {
|
|
12699
12937
|
if (actor.kind === "system") {
|
|
12700
12938
|
const provenance = {
|
|
12701
12939
|
authorId: actor.id,
|
|
12702
12940
|
sourceChatSessionId: actor.sourceChatSessionId
|
|
12703
12941
|
};
|
|
12704
|
-
if (isOutsideWriter(provenance))
|
|
12705
|
-
|
|
12706
|
-
|
|
12707
|
-
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
12942
|
+
if (isOutsideWriter(provenance)) {
|
|
12943
|
+
if (personWriterId !== null && provenance.authorId === personWriterId) return {
|
|
12944
|
+
kind: "user",
|
|
12945
|
+
id: provenance.authorId,
|
|
12946
|
+
displayName: "You",
|
|
12947
|
+
avatarUrl: null,
|
|
12948
|
+
label: null
|
|
12949
|
+
};
|
|
12950
|
+
return {
|
|
12951
|
+
kind: "system",
|
|
12952
|
+
id: provenance.authorId,
|
|
12953
|
+
displayName: provenance.authorId,
|
|
12954
|
+
avatarUrl: null,
|
|
12955
|
+
label: "External"
|
|
12956
|
+
};
|
|
12957
|
+
}
|
|
12711
12958
|
return {
|
|
12712
12959
|
kind: "system",
|
|
12713
12960
|
id: null,
|
|
@@ -12743,13 +12990,13 @@ function resolveActor(actor) {
|
|
|
12743
12990
|
* serves, named by its identity; actors as `resolveActor` reads them; and an
|
|
12744
12991
|
* agent assignee by its id.
|
|
12745
12992
|
*/
|
|
12746
|
-
function createActivityResolver(workspace) {
|
|
12993
|
+
function createActivityResolver(workspace, personWriterId = null) {
|
|
12747
12994
|
return {
|
|
12748
12995
|
workspaceById: /* @__PURE__ */ new Map([[workspace.id, {
|
|
12749
12996
|
id: workspace.id,
|
|
12750
12997
|
name: workspace.name
|
|
12751
12998
|
}]]),
|
|
12752
|
-
resolveActor: async (actor) => resolveActor(actor),
|
|
12999
|
+
resolveActor: async (actor) => resolveActor(actor, personWriterId),
|
|
12753
13000
|
resolveAgentName: async (agentId) => agentId
|
|
12754
13001
|
};
|
|
12755
13002
|
}
|
|
@@ -12770,10 +13017,15 @@ function createActivityResolver(workspace) {
|
|
|
12770
13017
|
const OK = { ok: true };
|
|
12771
13018
|
/** How many cards a search answers with when the caller names no limit. */
|
|
12772
13019
|
const DEFAULT_SEARCH_LIMIT = 20;
|
|
12773
|
-
|
|
13020
|
+
/**
|
|
13021
|
+
* `personWriterId` is the id the person who started the server is filed under;
|
|
13022
|
+
* their writes read as `You`, everyone else's by name.
|
|
13023
|
+
*/
|
|
13024
|
+
function createIssueRoutes(session, options = {}) {
|
|
13025
|
+
const personWriterId = options.personWriterId ?? null;
|
|
12774
13026
|
const { ops, store, workspace } = session;
|
|
12775
13027
|
const workspaceId = workspace.id;
|
|
12776
|
-
const activityResolver = createActivityResolver(workspace);
|
|
13028
|
+
const activityResolver = createActivityResolver(workspace, personWriterId);
|
|
12777
13029
|
/** The served workspace, when that is the one the caller named — or named none. */
|
|
12778
13030
|
function requireWorkspace(named) {
|
|
12779
13031
|
if (named !== void 0 && named !== workspaceId) throw new require_migrations_path.BoardError("issue_workspace_not_found", { workspaceId: named });
|
|
@@ -13058,6 +13310,15 @@ function createIssueRoutes(session) {
|
|
|
13058
13310
|
return updated;
|
|
13059
13311
|
}
|
|
13060
13312
|
}),
|
|
13313
|
+
route({
|
|
13314
|
+
method: "GET",
|
|
13315
|
+
path: "/issues/workspace",
|
|
13316
|
+
handle: async () => ({
|
|
13317
|
+
id: workspace.id,
|
|
13318
|
+
identifier: workspace.identifier,
|
|
13319
|
+
name: workspace.name
|
|
13320
|
+
})
|
|
13321
|
+
}),
|
|
13061
13322
|
route({
|
|
13062
13323
|
method: "GET",
|
|
13063
13324
|
path: "/issues/:id",
|
|
@@ -13211,7 +13472,7 @@ function createIssueRoutes(session) {
|
|
|
13211
13472
|
path: "/issues/:id/comments",
|
|
13212
13473
|
handle: async ({ params }) => {
|
|
13213
13474
|
await requireCard(params.id);
|
|
13214
|
-
return (await ops.listComments(params.id)).map(toCommentView);
|
|
13475
|
+
return (await ops.listComments(params.id)).map((comment) => toCommentView(comment, personWriterId));
|
|
13215
13476
|
}
|
|
13216
13477
|
}),
|
|
13217
13478
|
route({
|
|
@@ -13224,7 +13485,7 @@ function createIssueRoutes(session) {
|
|
|
13224
13485
|
return toCommentView(await ops.addComment({
|
|
13225
13486
|
issueId: params.id,
|
|
13226
13487
|
content: body.content
|
|
13227
|
-
}, actors.
|
|
13488
|
+
}, actors.person), personWriterId);
|
|
13228
13489
|
}
|
|
13229
13490
|
}),
|
|
13230
13491
|
route({
|
|
@@ -13353,7 +13614,7 @@ function assertServeBinding(host, token) {
|
|
|
13353
13614
|
}
|
|
13354
13615
|
async function startKanboServer(options) {
|
|
13355
13616
|
assertServeBinding(options.host, options.token);
|
|
13356
|
-
const routes = createIssueRoutes(options.session);
|
|
13617
|
+
const routes = createIssueRoutes(options.session, { personWriterId: options.actors.writer.id });
|
|
13357
13618
|
const listenHost = stripBrackets(options.host);
|
|
13358
13619
|
let boundPort = 0;
|
|
13359
13620
|
const handle = createRequestHandler(routes, options, () => boundPort);
|
|
@@ -13399,6 +13660,11 @@ const SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
|
13399
13660
|
"HEAD",
|
|
13400
13661
|
"OPTIONS"
|
|
13401
13662
|
]);
|
|
13663
|
+
/** What the board page tells the server about itself. */
|
|
13664
|
+
const PRESENCE_BODY = zod.z.object({
|
|
13665
|
+
tabId: zod.z.string().min(1).max(64).regex(/^[A-Za-z0-9-]+$/),
|
|
13666
|
+
state: zod.z.enum(["open", "closed"])
|
|
13667
|
+
});
|
|
13402
13668
|
function createRequestHandler(routes, options, boundPort) {
|
|
13403
13669
|
const token = options.token || null;
|
|
13404
13670
|
const corsOrigins = new Set(options.corsOrigins ?? []);
|
|
@@ -13459,17 +13725,41 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
13459
13725
|
throw new ServeError(401, "unauthorized", "A valid bearer token is required");
|
|
13460
13726
|
}
|
|
13461
13727
|
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");
|
|
13462
|
-
|
|
13463
|
-
|
|
13728
|
+
if (url.pathname === "/page/presence" && request.method === "POST") {
|
|
13729
|
+
if (!options.presence) {
|
|
13730
|
+
response.writeHead(204).end();
|
|
13731
|
+
return;
|
|
13732
|
+
}
|
|
13733
|
+
const parsed = PRESENCE_BODY.safeParse(await readJsonBody(request));
|
|
13734
|
+
if (!parsed.success) throw validationError("body", parsed.error);
|
|
13735
|
+
if (parsed.data.state === "open") options.presence.seen(parsed.data.tabId);
|
|
13736
|
+
else options.presence.closed(parsed.data.tabId);
|
|
13737
|
+
sendJson(response, 200, { ok: true });
|
|
13738
|
+
return;
|
|
13739
|
+
}
|
|
13740
|
+
const projectPath = readProjectPath(url.pathname);
|
|
13741
|
+
if (url.pathname === "/projects" && request.method === "GET" && options.projects) {
|
|
13742
|
+
sendJson(response, 200, options.projects.list());
|
|
13743
|
+
return;
|
|
13744
|
+
}
|
|
13745
|
+
const matched = projectPath ? null : matchRoute(routes, request.method ?? "GET", url.pathname);
|
|
13746
|
+
if (!projectPath && !matched) throw new ServeError(404, "not_found", "Not Found");
|
|
13747
|
+
if (projectPath && !options.projects) throw new ServeError(404, "not_found", "Not Found");
|
|
13464
13748
|
const body = await readJsonBody(request);
|
|
13465
13749
|
const actors = resolveRequestActors(options.actors, {
|
|
13466
13750
|
actorHeader: request.headers[ACTOR_HEADER],
|
|
13467
13751
|
authenticated: token !== null
|
|
13468
13752
|
});
|
|
13469
13753
|
const result = queue.then(async () => {
|
|
13470
|
-
|
|
13471
|
-
|
|
13472
|
-
|
|
13754
|
+
const target = projectPath && options.projects ? await options.projects.resolve(projectPath.projectId) : {
|
|
13755
|
+
session: options.session,
|
|
13756
|
+
routes
|
|
13757
|
+
};
|
|
13758
|
+
const found = matched ?? matchRoute(target.routes, request.method ?? "GET", projectPath?.rest ?? url.pathname);
|
|
13759
|
+
if (!found) throw new ServeError(404, "not_found", "Not Found");
|
|
13760
|
+
if (found.route.write) await target.session.assertWritable();
|
|
13761
|
+
return await found.route.run({
|
|
13762
|
+
params: found.params,
|
|
13473
13763
|
query: readQuery(url.searchParams),
|
|
13474
13764
|
body,
|
|
13475
13765
|
actors
|
|
@@ -13495,6 +13785,21 @@ function createRequestHandler(routes, options, boundPort) {
|
|
|
13495
13785
|
}
|
|
13496
13786
|
};
|
|
13497
13787
|
}
|
|
13788
|
+
/** `/projects/<id>/issues/...` as the project's id and the `/issues/...` path under it; `null` for any other path. */
|
|
13789
|
+
function readProjectPath(pathname) {
|
|
13790
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
13791
|
+
if (segments[0] !== "projects" || segments.length < 3 || segments[2] !== "issues") return null;
|
|
13792
|
+
let projectId;
|
|
13793
|
+
try {
|
|
13794
|
+
projectId = decodeURIComponent(segments[1]);
|
|
13795
|
+
} catch {
|
|
13796
|
+
return null;
|
|
13797
|
+
}
|
|
13798
|
+
return {
|
|
13799
|
+
projectId,
|
|
13800
|
+
rest: `/${segments.slice(2).join("/")}`
|
|
13801
|
+
};
|
|
13802
|
+
}
|
|
13498
13803
|
function isJsonContentType(value) {
|
|
13499
13804
|
return value?.split(";")[0].trim().toLowerCase() === "application/json";
|
|
13500
13805
|
}
|
|
@@ -13638,6 +13943,146 @@ function readTextOrNull(path) {
|
|
|
13638
13943
|
}
|
|
13639
13944
|
}
|
|
13640
13945
|
//#endregion
|
|
13946
|
+
//#region src/serve/page-presence.ts
|
|
13947
|
+
function createPagePresence(options) {
|
|
13948
|
+
const closedGraceMs = options.closedGraceMs ?? 5e3;
|
|
13949
|
+
const staleMs = options.staleMs ?? 12e4;
|
|
13950
|
+
const tickMs = options.tickMs ?? 1e4;
|
|
13951
|
+
const now = options.now ?? Date.now;
|
|
13952
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
13953
|
+
let finished = false;
|
|
13954
|
+
let graceTimer = null;
|
|
13955
|
+
let lastTick = now();
|
|
13956
|
+
function stop() {
|
|
13957
|
+
finished = true;
|
|
13958
|
+
clearInterval(ticker);
|
|
13959
|
+
if (graceTimer) {
|
|
13960
|
+
clearTimeout(graceTimer);
|
|
13961
|
+
graceTimer = null;
|
|
13962
|
+
}
|
|
13963
|
+
}
|
|
13964
|
+
function idle() {
|
|
13965
|
+
if (finished) return;
|
|
13966
|
+
stop();
|
|
13967
|
+
options.onIdle();
|
|
13968
|
+
}
|
|
13969
|
+
const ticker = setInterval(() => {
|
|
13970
|
+
const current = now();
|
|
13971
|
+
const asleep = current - lastTick > 3 * tickMs;
|
|
13972
|
+
lastTick = current;
|
|
13973
|
+
if (asleep) {
|
|
13974
|
+
for (const id of tabs.keys()) tabs.set(id, current);
|
|
13975
|
+
return;
|
|
13976
|
+
}
|
|
13977
|
+
if (tabs.size === 0) return;
|
|
13978
|
+
for (const [id, lastSeen] of tabs) if (current - lastSeen > staleMs) tabs.delete(id);
|
|
13979
|
+
if (tabs.size === 0) idle();
|
|
13980
|
+
}, tickMs);
|
|
13981
|
+
ticker.unref();
|
|
13982
|
+
return {
|
|
13983
|
+
seen: (tabId) => {
|
|
13984
|
+
if (!finished) tabs.set(tabId, now());
|
|
13985
|
+
},
|
|
13986
|
+
closed: (tabId) => {
|
|
13987
|
+
if (finished || !tabs.delete(tabId) || tabs.size > 0) return;
|
|
13988
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
13989
|
+
graceTimer = setTimeout(() => {
|
|
13990
|
+
graceTimer = null;
|
|
13991
|
+
if (tabs.size === 0) idle();
|
|
13992
|
+
}, closedGraceMs);
|
|
13993
|
+
graceTimer.unref();
|
|
13994
|
+
},
|
|
13995
|
+
stop
|
|
13996
|
+
};
|
|
13997
|
+
}
|
|
13998
|
+
//#endregion
|
|
13999
|
+
//#region src/serve/projects.ts
|
|
14000
|
+
function createServeProjects(input) {
|
|
14001
|
+
const routesOptions = { personWriterId: input.personWriterId ?? null };
|
|
14002
|
+
const opened = /* @__PURE__ */ new Map();
|
|
14003
|
+
let defaultOpened = null;
|
|
14004
|
+
/** Every project that can be offered right now, default first, with the folder each lives in. */
|
|
14005
|
+
function entries() {
|
|
14006
|
+
const defaultDir = input.defaultProject?.dir ?? null;
|
|
14007
|
+
const dirs = [...defaultDir === null ? [] : [defaultDir], ...input.readRegistry().map((project) => project.path).filter((path) => path !== defaultDir)];
|
|
14008
|
+
const found = [];
|
|
14009
|
+
for (const dir of dirs) {
|
|
14010
|
+
const binding = input.readBinding(dir);
|
|
14011
|
+
if (binding === null && dir !== defaultDir) continue;
|
|
14012
|
+
found.push({
|
|
14013
|
+
view: viewOf(dir, binding, dir === defaultDir),
|
|
14014
|
+
dir
|
|
14015
|
+
});
|
|
14016
|
+
}
|
|
14017
|
+
return found;
|
|
14018
|
+
}
|
|
14019
|
+
return {
|
|
14020
|
+
list: () => entries().map((entry) => entry.view),
|
|
14021
|
+
resolve: async (projectId) => {
|
|
14022
|
+
const entry = entries().find((candidate) => candidate.view.id === projectId);
|
|
14023
|
+
if (!entry) throw new ServeError(404, "project_not_found", "No project with that id is on this computer's list");
|
|
14024
|
+
if (entry.view.isDefault && input.defaultProject) {
|
|
14025
|
+
defaultOpened ??= {
|
|
14026
|
+
session: input.defaultProject.session,
|
|
14027
|
+
routes: createIssueRoutes(input.defaultProject.session, routesOptions)
|
|
14028
|
+
};
|
|
14029
|
+
return defaultOpened;
|
|
14030
|
+
}
|
|
14031
|
+
const cached = opened.get(projectId);
|
|
14032
|
+
if (cached) return cached;
|
|
14033
|
+
let session;
|
|
14034
|
+
try {
|
|
14035
|
+
session = await input.openSession(entry.dir);
|
|
14036
|
+
} catch (error) {
|
|
14037
|
+
throw new ServeError(503, "project_unavailable", describeFailure(error).message);
|
|
14038
|
+
}
|
|
14039
|
+
const project = {
|
|
14040
|
+
session,
|
|
14041
|
+
routes: createIssueRoutes(session, routesOptions)
|
|
14042
|
+
};
|
|
14043
|
+
opened.set(projectId, project);
|
|
14044
|
+
return project;
|
|
14045
|
+
},
|
|
14046
|
+
close: async () => {
|
|
14047
|
+
const sessions = [...opened.values()].map((project) => project.session);
|
|
14048
|
+
opened.clear();
|
|
14049
|
+
await Promise.allSettled(sessions.map(async (session) => await session.close()));
|
|
14050
|
+
}
|
|
14051
|
+
};
|
|
14052
|
+
}
|
|
14053
|
+
function projectId(dir) {
|
|
14054
|
+
return (0, node_crypto.createHash)("sha256").update(dir).digest("hex").slice(0, 12);
|
|
14055
|
+
}
|
|
14056
|
+
function viewOf(dir, binding, isDefault) {
|
|
14057
|
+
return {
|
|
14058
|
+
id: projectId(dir),
|
|
14059
|
+
name: dir.split(/[\\/]/).filter(Boolean).pop() ?? dir,
|
|
14060
|
+
folder: tildify(dir),
|
|
14061
|
+
storage: storageOf(dir, binding),
|
|
14062
|
+
isDefault
|
|
14063
|
+
};
|
|
14064
|
+
}
|
|
14065
|
+
/** Where the board lives. The connection string is read here to name its host, and goes no further. */
|
|
14066
|
+
function storageOf(dir, binding) {
|
|
14067
|
+
if (binding?.databaseUrl) return {
|
|
14068
|
+
kind: "postgres",
|
|
14069
|
+
where: hostOf(maskDatabaseUrl(binding.databaseUrl))
|
|
14070
|
+
};
|
|
14071
|
+
return {
|
|
14072
|
+
kind: "file",
|
|
14073
|
+
path: tildify((0, node_path.resolve)(dir, binding?.dbPath ?? ".kanbo/board.db"))
|
|
14074
|
+
};
|
|
14075
|
+
}
|
|
14076
|
+
/** The host of an already masked connection string; a string that is not one names no host. */
|
|
14077
|
+
function hostOf(maskedUrl) {
|
|
14078
|
+
try {
|
|
14079
|
+
const { host } = new URL(maskedUrl);
|
|
14080
|
+
return host && !host.includes("*") ? host : "a Postgres database";
|
|
14081
|
+
} catch {
|
|
14082
|
+
return "a Postgres database";
|
|
14083
|
+
}
|
|
14084
|
+
}
|
|
14085
|
+
//#endregion
|
|
13641
14086
|
//#region src/cli/commands/serve.ts
|
|
13642
14087
|
function registerServeCommand(program) {
|
|
13643
14088
|
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) => {
|
|
@@ -13645,7 +14090,7 @@ function registerServeCommand(program) {
|
|
|
13645
14090
|
status: (line) => console.error(line),
|
|
13646
14091
|
page: (line) => console.log(line)
|
|
13647
14092
|
});
|
|
13648
|
-
await
|
|
14093
|
+
if (await waitForStop(running.idle) === "idle") console.error("kanbo serve: the last board tab was closed — stopped.");
|
|
13649
14094
|
try {
|
|
13650
14095
|
await running.close();
|
|
13651
14096
|
process.exit(0);
|
|
@@ -13657,26 +14102,46 @@ function registerServeCommand(program) {
|
|
|
13657
14102
|
}
|
|
13658
14103
|
/**
|
|
13659
14104
|
* Open the board and serve it — everything `kanbo serve` does but wait. The
|
|
13660
|
-
* command waits for Ctrl-C
|
|
13661
|
-
*
|
|
14105
|
+
* command waits for Ctrl-C (or the last board tab closing) and ends the
|
|
14106
|
+
* process; the home menu waits the same and comes back to its menu.
|
|
13662
14107
|
*/
|
|
13663
14108
|
async function startServe(options, output) {
|
|
13664
14109
|
const port = parsePort(options.port);
|
|
13665
14110
|
const access = resolveServeAccess(options.host, options.token?.trim() || process.env.KANBO_SERVE_TOKEN?.trim() || null);
|
|
14111
|
+
const here = findBinding(process.cwd());
|
|
14112
|
+
const mostRecent = here === null && namesNoBoard(options) ? readProjectRegistry()[0] : void 0;
|
|
14113
|
+
const defaultDir = here?.projectDir ?? mostRecent?.path ?? null;
|
|
13666
14114
|
const session = await openBoardSession({
|
|
13667
14115
|
db: options.db,
|
|
13668
14116
|
databaseUrl: options.databaseUrl,
|
|
13669
14117
|
workspace: options.workspace
|
|
13670
|
-
}, "read");
|
|
14118
|
+
}, "read", mostRecent?.path);
|
|
13671
14119
|
const agentShell = isAgentShell();
|
|
13672
14120
|
const actors = {
|
|
13673
|
-
writer:
|
|
14121
|
+
writer: createServeWriter(),
|
|
13674
14122
|
person: agentShell ? null : createPersonActor()
|
|
13675
14123
|
};
|
|
14124
|
+
const projects = createServeProjects({
|
|
14125
|
+
defaultProject: defaultDir === null ? null : {
|
|
14126
|
+
dir: defaultDir,
|
|
14127
|
+
session
|
|
14128
|
+
},
|
|
14129
|
+
readRegistry: () => readProjectRegistry(),
|
|
14130
|
+
readBinding: (dir) => readProjectBinding(dir),
|
|
14131
|
+
openSession: async (dir) => await openBoardSession({}, "read", dir),
|
|
14132
|
+
personWriterId: actors.writer.id
|
|
14133
|
+
});
|
|
14134
|
+
const opensBrowser = !agentShell && process.stdout.isTTY === true && options.open;
|
|
14135
|
+
let presence;
|
|
14136
|
+
const idle = new Promise((resolve) => {
|
|
14137
|
+
presence = opensBrowser ? createPagePresence({ onIdle: resolve }) : void 0;
|
|
14138
|
+
});
|
|
13676
14139
|
let server;
|
|
13677
14140
|
try {
|
|
13678
14141
|
server = await startKanboServer({
|
|
13679
14142
|
session,
|
|
14143
|
+
projects,
|
|
14144
|
+
presence,
|
|
13680
14145
|
actors,
|
|
13681
14146
|
host: options.host,
|
|
13682
14147
|
port,
|
|
@@ -13684,10 +14149,12 @@ async function startServe(options, output) {
|
|
|
13684
14149
|
corsOrigins: options.corsOrigin
|
|
13685
14150
|
});
|
|
13686
14151
|
} catch (error) {
|
|
14152
|
+
presence?.stop();
|
|
13687
14153
|
await session.close();
|
|
13688
14154
|
throw error;
|
|
13689
14155
|
}
|
|
13690
|
-
|
|
14156
|
+
const projectCount = projects.list().length;
|
|
14157
|
+
output.status(`kanbo serve: ${server.url} — workspace ${session.workspace.id}, ${projectCount} ${projectCount === 1 ? "project" : "projects"}, bearer token required${access.generated ? " (generated for this run)" : ""}`);
|
|
13691
14158
|
output.status(`kanbo serve: ${describePersonRights(actors)}`);
|
|
13692
14159
|
presentServePage({
|
|
13693
14160
|
url: server.url,
|
|
@@ -13701,25 +14168,40 @@ async function startServe(options, output) {
|
|
|
13701
14168
|
});
|
|
13702
14169
|
return {
|
|
13703
14170
|
link: access.generated ? `${server.url}/#token=${access.token}` : `${server.url}/`,
|
|
14171
|
+
idle,
|
|
13704
14172
|
close: async () => {
|
|
14173
|
+
presence?.stop();
|
|
13705
14174
|
try {
|
|
13706
14175
|
await server.close();
|
|
13707
14176
|
} finally {
|
|
13708
|
-
|
|
14177
|
+
try {
|
|
14178
|
+
await projects.close();
|
|
14179
|
+
} finally {
|
|
14180
|
+
await session.close();
|
|
14181
|
+
}
|
|
13709
14182
|
}
|
|
13710
14183
|
}
|
|
13711
14184
|
};
|
|
13712
14185
|
}
|
|
13713
|
-
/**
|
|
13714
|
-
function
|
|
14186
|
+
/** Whether neither a flag nor the environment says which board to open. */
|
|
14187
|
+
function namesNoBoard(options) {
|
|
14188
|
+
return !options.db && !options.databaseUrl && !options.workspace && !process.env.KANBO_DB_PATH && !process.env.KANBO_DATABASE_URL && !process.env.KANBO_WORKSPACE_ID;
|
|
14189
|
+
}
|
|
14190
|
+
/**
|
|
14191
|
+
* Settles on the first Ctrl-C (SIGINT) or SIGTERM, or when `idle` settles, and
|
|
14192
|
+
* stops listening for both signals whichever it was.
|
|
14193
|
+
*/
|
|
14194
|
+
function waitForStop(idle) {
|
|
13715
14195
|
return new Promise((resolve) => {
|
|
13716
|
-
const
|
|
13717
|
-
process.off("SIGINT",
|
|
13718
|
-
process.off("SIGTERM",
|
|
13719
|
-
resolve();
|
|
14196
|
+
const finish = (why) => {
|
|
14197
|
+
process.off("SIGINT", onSignal);
|
|
14198
|
+
process.off("SIGTERM", onSignal);
|
|
14199
|
+
resolve(why);
|
|
13720
14200
|
};
|
|
13721
|
-
|
|
13722
|
-
process.on("
|
|
14201
|
+
const onSignal = () => finish("interrupt");
|
|
14202
|
+
process.on("SIGINT", onSignal);
|
|
14203
|
+
process.on("SIGTERM", onSignal);
|
|
14204
|
+
idle.then(() => finish("idle"));
|
|
13723
14205
|
});
|
|
13724
14206
|
}
|
|
13725
14207
|
function collect(value, previous) {
|
|
@@ -14043,7 +14525,7 @@ const KANBO_TOOL_CLI_EQUIVALENTS = {
|
|
|
14043
14525
|
kanbo_card_get: { kanbo: "kanbo card get <id>" },
|
|
14044
14526
|
kanbo_card_list: { kanbo: "kanbo card list" },
|
|
14045
14527
|
kanbo_card_create: { kanbo: "kanbo card create --description <text> [--title <title>] [--parent <id>]" },
|
|
14046
|
-
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>]" },
|
|
14528
|
+
kanbo_card_update: { kanbo: "kanbo card update <id> [--title <title>] [--description <text>] [--parent <id>]" },
|
|
14047
14529
|
kanbo_card_move: { kanbo: "kanbo card move <id> <column>" },
|
|
14048
14530
|
kanbo_card_comment: { kanbo: "kanbo card comment <id> --content <text>" },
|
|
14049
14531
|
kanbo_card_link_pr: { kanbo: "kanbo card pr add <id> <url>" },
|