ikanban 0.2.1 → 0.3.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/README.md CHANGED
@@ -7,8 +7,22 @@ results into [iKanban](https://i-kanban.com).
7
7
  npx ikanban secscan # dependencies, committed secrets, risky file permissions
8
8
  npx ikanban aiscan --help # red-team scan of your AI app (OWASP LLM Top 10)
9
9
  npx ikanban secscan --publish # also file the report and findings into iKanban
10
+ npx ikanban roadmap sync plan.json # make the Roadmap plan match the plan file
11
+ npx ikanban hooks install # sync plan files on every agent write and git push
10
12
  ```
11
13
 
14
+ ## Roadmap follows the plan file
15
+
16
+ A plan file is a JSON file with a top-level `roadmap` block (`team`, `project`, and `plan_id`
17
+ after the first sync) and `phases`, each with an `id`, `title`, `status`, `depends_on` and
18
+ `tasks` (`id`, `description`, `status`). `ikanban roadmap sync` sends it to iKanban, which
19
+ creates or updates the plan's phases and tasks, matched by id. `--check` changes nothing and
20
+ exits 1 when the Roadmap differs. With no file it syncs every tracked plan file.
21
+
22
+ `ikanban hooks install` adds a git `pre-push` hook (any git host, never blocks a push) and an
23
+ agent hook for Claude Code, Gemini CLI and Copilot CLI (`.iKanban/hooks/roadmap-sync.js`).
24
+ No CI is needed.
25
+
12
26
  ## What you need
13
27
 
14
28
  - Node.js 18 or newer.
package/bin/ikanban.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // ikanban: one CLI for iKanban. Each command runs a bundled Python scanner.
2
+ // ikanban: one CLI for iKanban. The scan commands run a bundled Python scanner;
3
+ // roadmap and hooks keep the Roadmap in step with a plan file (lib/roadmap.js).
3
4
  // Scans run free on your machine; only --publish talks to iKanban, and the
4
5
  // server checks your plan there (a 402 means the plan, not the scan).
5
- const { spawnSync } = require("node:child_process");
6
6
  const path = require("node:path");
7
+ const { runCommand } = require("../lib/ikanban-safe.js");
7
8
  const { version } = require("../package.json");
8
9
 
9
10
  const COMMANDS = {
@@ -19,6 +20,10 @@ Usage: ikanban <command> [options]
19
20
 
20
21
  Commands:
21
22
  ${lines.join("\n")}
23
+ roadmap Keep a Roadmap plan in step with its plan file: roadmap sync [plan.json] [--check]
24
+ hooks Install the hooks that run that sync on every agent write and git push
25
+
26
+ pipeline Report a task's stage and stream its log to iKanban (any CI)
22
27
 
23
28
  Run "ikanban <command> --help" for its options. Add --publish to file the
24
29
  results into iKanban (needs an API token; the server checks your plan).
@@ -31,7 +36,7 @@ function findPython() {
31
36
  ? [["py", ["-3"]], ["python", []], ["python3", []]]
32
37
  : [["python3", []], ["python", []]];
33
38
  for (const [cmd, pre] of candidates) {
34
- const r = spawnSync(cmd, [...pre, "-c", "import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)"]);
39
+ const r = runCommand(cmd, [...pre, "-c", "import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)"]);
35
40
  if (r.status === 0) return [cmd, pre];
36
41
  }
37
42
  return null;
@@ -46,6 +51,21 @@ if (cmd === "--version" || cmd === "-v") {
46
51
  console.log(version);
47
52
  process.exit(0);
48
53
  }
54
+ if (cmd === "roadmap" || cmd === "hooks") {
55
+ require("../lib/roadmap.js").run(cmd, args).then((code) => process.exit(code), (e) => {
56
+ console.error(`ikanban: ${e.message}`);
57
+ process.exit(1);
58
+ });
59
+ return;
60
+ }
61
+ // Plan 21 F10: report a task's pipeline stages and stream their logs, from any CI.
62
+ if (cmd === "pipeline") {
63
+ require("../lib/pipeline.js").run(args).then((code) => process.exit(code), (e) => {
64
+ console.error(`ikanban pipeline: ${e.message}`);
65
+ process.exit(1);
66
+ });
67
+ return;
68
+ }
49
69
  const command = COMMANDS[cmd];
50
70
  if (!command) {
51
71
  console.error(`ikanban: unknown command "${cmd}". Run "ikanban --help".`);
@@ -60,5 +80,5 @@ const [py, pre] = python;
60
80
  const script = path.join(__dirname, "..", "scanners", command.file);
61
81
  // Windows consoles default to cp1252, which lacks characters the reports print.
62
82
  const env = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" };
63
- const r = spawnSync(py, [...pre, script, ...args], { stdio: "inherit", env });
83
+ const r = runCommand(py, [...pre, script, ...args], { stdio: "inherit", env });
64
84
  process.exit(r.status === null ? 1 : r.status);
@@ -0,0 +1,96 @@
1
+ // iKanban: the two safe ways for a hook or the CLI to touch the outside world.
2
+ // runCommand(file, args, opts) runs one program with an argument list, never a shell,
3
+ // so a file name, branch or plan text stays one argument.
4
+ // startCommand(file, args, opts) the same rule, but returns the running child (spawn), for
5
+ // output that has to stream while the program runs.
6
+ // ikanbanFetch(path, opts) calls iKanban's API with your key. The key goes only to
7
+ // iKanban's API (or this machine), never on a redirect,
8
+ // and is never printed.
9
+ // The `ikanban` npm package carries a copy (vibe-cli/lib/); this file is the source.
10
+ const path = require("node:path");
11
+ const { spawn, spawnSync } = require("node:child_process");
12
+
13
+ const API_ORIGINS = new Set(["https://api.i-kanban.com", "https://dev-api.i-kanban.com"]);
14
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]);
15
+
16
+ /** The checks both runners share: one program, a list of string arguments, no shell. */
17
+ function checkCommand(name, file, args, opts) {
18
+ if (opts.shell) throw new Error(`${name} never runs a shell; pass each value as its own argument`);
19
+ if (typeof file !== "string" || !file || (/\s/.test(file) && !path.isAbsolute(file))) {
20
+ throw new Error(`${name} runs one program; "${file}" looks like a command line. Put its words in the argument list`);
21
+ }
22
+ if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) {
23
+ throw new Error(`${name} takes its arguments as a list of strings`);
24
+ }
25
+ }
26
+
27
+ /** spawn with the same rules as runCommand; returns the child process. */
28
+ function startCommand(file, args = [], opts = {}) {
29
+ checkCommand("startCommand", file, args, opts);
30
+ if (process.platform === "win32" && (file === "npx" || file === "npm")) {
31
+ const cli = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", `${file}-cli.js`);
32
+ return spawn(process.execPath, [cli, ...args], { ...opts, shell: false });
33
+ }
34
+ return spawn(file, args, { ...opts, shell: false });
35
+ }
36
+
37
+ /** spawnSync with an argument list and no shell. Returns spawnSync's result. */
38
+ function runCommand(file, args = [], opts = {}) {
39
+ checkCommand("runCommand", file, args, opts);
40
+ // Windows runs npm and npx through .cmd files, which need a shell; run npm's own script with node instead.
41
+ // ponytail: assumes the standard Node layout on Windows (npm next to node.exe); a custom layout fails clearly.
42
+ if (process.platform === "win32" && (file === "npx" || file === "npm")) {
43
+ const cli = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", `${file}-cli.js`);
44
+ return spawnSync(process.execPath, [cli, ...args], { ...opts, shell: false });
45
+ }
46
+ return spawnSync(file, args, { ...opts, shell: false });
47
+ }
48
+
49
+ /** The origin of an iKanban API address, or an error that names it (never the key). */
50
+ function apiOrigin(base) {
51
+ let u;
52
+ try {
53
+ u = new URL(String(base || ""));
54
+ } catch {
55
+ u = null;
56
+ }
57
+ const ok = u && !u.username && !u.password && u.pathname === "/" && !u.search &&
58
+ (API_ORIGINS.has(u.origin) || (u.protocol === "http:" && LOOPBACK.has(u.hostname)));
59
+ if (!ok) {
60
+ throw new Error(`${base || "(empty)"} is not an iKanban API (${[...API_ORIGINS].join(", ")} or this machine); the key was not sent`);
61
+ }
62
+ return u.origin;
63
+ }
64
+
65
+ /**
66
+ * fetch() to iKanban's API. opts: base (the signed-in api_url), token, method, body (sent as
67
+ * JSON), timeoutMs. Refuses another host, an absolute or protocol-relative path, and redirects.
68
+ */
69
+ async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeoutMs = 60000 } = {}) {
70
+ const origin = apiOrigin(base);
71
+ if (typeof apiPath !== "string" || !apiPath.startsWith("/") || apiPath.startsWith("//")) {
72
+ throw new Error(`"${apiPath}" is not a path on the iKanban API; the key was not sent`);
73
+ }
74
+ const url = new URL(apiPath, origin);
75
+ if (url.origin !== origin) throw new Error(`"${apiPath}" leaves ${origin}; the key was not sent`);
76
+ const headers = { Authorization: `Bearer ${token}` };
77
+ if (body !== undefined) headers["Content-Type"] = "application/json";
78
+ let res;
79
+ try {
80
+ res = await fetch(url, {
81
+ method,
82
+ headers,
83
+ body: body === undefined ? undefined : JSON.stringify(body),
84
+ redirect: "manual",
85
+ signal: AbortSignal.timeout(timeoutMs),
86
+ });
87
+ } catch (e) {
88
+ throw new Error(`cannot reach ${origin} (${e.cause?.code || e.name})`);
89
+ }
90
+ if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
91
+ throw new Error(`${origin} answered with a redirect (HTTP ${res.status}); it was not followed, so the key went nowhere else`);
92
+ }
93
+ return res;
94
+ }
95
+
96
+ module.exports = { runCommand, startCommand, ikanbanFetch, apiOrigin };
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // iKanban: keep the Roadmap in step with the plan file. An agent hook runs this
3
+ // after each file write (Claude Code PostToolUse, Gemini CLI AfterTool, Copilot
4
+ // CLI postToolUse; `npx ikanban hooks install` wires them). It reads the hook's
5
+ // JSON on stdin, and only when the written file is a plan file (a JSON file with
6
+ // a top-level "roadmap" block) it runs `ikanban roadmap sync <file>`.
7
+ // It never blocks the agent: it always exits 0 and prints "{}" (the hook answer
8
+ // every agent accepts); messages go to stderr.
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+ const { runCommand } = require("./ikanban-safe.js");
12
+
13
+ function filePaths(value, out = []) {
14
+ if (typeof value === "string") {
15
+ // Copilot CLI sends the tool arguments as a JSON string.
16
+ if (value.trim().startsWith("{")) {
17
+ try { filePaths(JSON.parse(value), out); } catch { /* not JSON */ }
18
+ }
19
+ return out;
20
+ }
21
+ if (value && typeof value === "object") {
22
+ for (const [k, v] of Object.entries(value)) {
23
+ if (["file_path", "path", "absolute_path", "filePath"].includes(k) && typeof v === "string") out.push(v);
24
+ else filePaths(v, out);
25
+ }
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** The repository root (git), so a plan file outside it is never synced. */
31
+ function repoRoot(cwd) {
32
+ const r = runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
33
+ return r.status === 0 ? fs.realpathSync(r.stdout.trim()) : null;
34
+ }
35
+
36
+ /** A registered plan file: a .json file inside the repository with a top-level "roadmap" block. */
37
+ function isPlan(file, root) {
38
+ if (!file.endsWith(".json") || !fs.existsSync(file)) return false;
39
+ const real = fs.realpathSync(file);
40
+ const rel = path.relative(root, real);
41
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
42
+ try {
43
+ const doc = JSON.parse(fs.readFileSync(real, "utf8"));
44
+ return Boolean(doc && doc.roadmap && typeof doc.roadmap === "object" && !Array.isArray(doc.roadmap));
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ /** How to run the CLI as an argument list, never through a shell: IKANBAN_CLI
51
+ * (a laptop's local build, split on spaces), else npm's own npx script run by
52
+ * this node, else the npx binary (macOS, Linux). */
53
+ function cliCommand() {
54
+ if (process.env.IKANBAN_CLI) {
55
+ const [cmd, ...pre] = process.env.IKANBAN_CLI.split(" ").filter(Boolean);
56
+ return [cmd, pre];
57
+ }
58
+ const dir = path.dirname(process.execPath);
59
+ for (const npx of [path.join(dir, "node_modules", "npm", "bin", "npx-cli.js"),
60
+ path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js")]) {
61
+ if (fs.existsSync(npx)) return [process.execPath, [npx, "-y", "ikanban"]];
62
+ }
63
+ return ["npx", ["-y", "ikanban"]];
64
+ }
65
+
66
+ function main(input) {
67
+ let event = {};
68
+ try { event = JSON.parse(input || "{}"); } catch { return; }
69
+ if (!event || typeof event !== "object") return;
70
+ const cwd = typeof event.cwd === "string" && fs.existsSync(event.cwd) ? event.cwd : process.cwd();
71
+ const root = repoRoot(cwd);
72
+ if (!root) return;
73
+ const args = event.tool_input || event.toolArgs || event.tool_args || {};
74
+ const plans = [...new Set(filePaths(args).map((p) => path.resolve(cwd, p)))].filter((f) => isPlan(f, root));
75
+ const [cmd, pre] = cliCommand();
76
+ for (const plan of plans) {
77
+ // An argument array and no shell: nothing from the hook input or the plan is
78
+ // ever read by a shell. The path is absolute, so it cannot look like an option.
79
+ const r = runCommand(cmd, [...pre, "roadmap", "sync", fs.realpathSync(plan)], {
80
+ cwd: root, encoding: "utf8", shell: false, timeout: 60000,
81
+ });
82
+ process.stderr.write(`iKanban Roadmap: ${(r.stdout || "").trim() || (r.stderr || "").trim() || "sync did not run"}\n`);
83
+ }
84
+ }
85
+
86
+ let input = "";
87
+ process.stdin.on("data", (c) => { input += c; });
88
+ process.stdin.on("end", () => {
89
+ try { main(input); } catch (e) { process.stderr.write(`iKanban Roadmap hook: ${e.message}\n`); }
90
+ process.stdout.write("{}\n");
91
+ process.exit(0);
92
+ });
@@ -0,0 +1,96 @@
1
+ // iKanban: the two safe ways for a hook or the CLI to touch the outside world.
2
+ // runCommand(file, args, opts) runs one program with an argument list, never a shell,
3
+ // so a file name, branch or plan text stays one argument.
4
+ // startCommand(file, args, opts) the same rule, but returns the running child (spawn), for
5
+ // output that has to stream while the program runs.
6
+ // ikanbanFetch(path, opts) calls iKanban's API with your key. The key goes only to
7
+ // iKanban's API (or this machine), never on a redirect,
8
+ // and is never printed.
9
+ // The `ikanban` npm package carries a copy (vibe-cli/lib/); this file is the source.
10
+ const path = require("node:path");
11
+ const { spawn, spawnSync } = require("node:child_process");
12
+
13
+ const API_ORIGINS = new Set(["https://api.i-kanban.com", "https://dev-api.i-kanban.com"]);
14
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"]);
15
+
16
+ /** The checks both runners share: one program, a list of string arguments, no shell. */
17
+ function checkCommand(name, file, args, opts) {
18
+ if (opts.shell) throw new Error(`${name} never runs a shell; pass each value as its own argument`);
19
+ if (typeof file !== "string" || !file || (/\s/.test(file) && !path.isAbsolute(file))) {
20
+ throw new Error(`${name} runs one program; "${file}" looks like a command line. Put its words in the argument list`);
21
+ }
22
+ if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) {
23
+ throw new Error(`${name} takes its arguments as a list of strings`);
24
+ }
25
+ }
26
+
27
+ /** spawn with the same rules as runCommand; returns the child process. */
28
+ function startCommand(file, args = [], opts = {}) {
29
+ checkCommand("startCommand", file, args, opts);
30
+ if (process.platform === "win32" && (file === "npx" || file === "npm")) {
31
+ const cli = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", `${file}-cli.js`);
32
+ return spawn(process.execPath, [cli, ...args], { ...opts, shell: false });
33
+ }
34
+ return spawn(file, args, { ...opts, shell: false });
35
+ }
36
+
37
+ /** spawnSync with an argument list and no shell. Returns spawnSync's result. */
38
+ function runCommand(file, args = [], opts = {}) {
39
+ checkCommand("runCommand", file, args, opts);
40
+ // Windows runs npm and npx through .cmd files, which need a shell; run npm's own script with node instead.
41
+ // ponytail: assumes the standard Node layout on Windows (npm next to node.exe); a custom layout fails clearly.
42
+ if (process.platform === "win32" && (file === "npx" || file === "npm")) {
43
+ const cli = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", `${file}-cli.js`);
44
+ return spawnSync(process.execPath, [cli, ...args], { ...opts, shell: false });
45
+ }
46
+ return spawnSync(file, args, { ...opts, shell: false });
47
+ }
48
+
49
+ /** The origin of an iKanban API address, or an error that names it (never the key). */
50
+ function apiOrigin(base) {
51
+ let u;
52
+ try {
53
+ u = new URL(String(base || ""));
54
+ } catch {
55
+ u = null;
56
+ }
57
+ const ok = u && !u.username && !u.password && u.pathname === "/" && !u.search &&
58
+ (API_ORIGINS.has(u.origin) || (u.protocol === "http:" && LOOPBACK.has(u.hostname)));
59
+ if (!ok) {
60
+ throw new Error(`${base || "(empty)"} is not an iKanban API (${[...API_ORIGINS].join(", ")} or this machine); the key was not sent`);
61
+ }
62
+ return u.origin;
63
+ }
64
+
65
+ /**
66
+ * fetch() to iKanban's API. opts: base (the signed-in api_url), token, method, body (sent as
67
+ * JSON), timeoutMs. Refuses another host, an absolute or protocol-relative path, and redirects.
68
+ */
69
+ async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeoutMs = 60000 } = {}) {
70
+ const origin = apiOrigin(base);
71
+ if (typeof apiPath !== "string" || !apiPath.startsWith("/") || apiPath.startsWith("//")) {
72
+ throw new Error(`"${apiPath}" is not a path on the iKanban API; the key was not sent`);
73
+ }
74
+ const url = new URL(apiPath, origin);
75
+ if (url.origin !== origin) throw new Error(`"${apiPath}" leaves ${origin}; the key was not sent`);
76
+ const headers = { Authorization: `Bearer ${token}` };
77
+ if (body !== undefined) headers["Content-Type"] = "application/json";
78
+ let res;
79
+ try {
80
+ res = await fetch(url, {
81
+ method,
82
+ headers,
83
+ body: body === undefined ? undefined : JSON.stringify(body),
84
+ redirect: "manual",
85
+ signal: AbortSignal.timeout(timeoutMs),
86
+ });
87
+ } catch (e) {
88
+ throw new Error(`cannot reach ${origin} (${e.cause?.code || e.name})`);
89
+ }
90
+ if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
91
+ throw new Error(`${origin} answered with a redirect (HTTP ${res.status}); it was not followed, so the key went nowhere else`);
92
+ }
93
+ return res;
94
+ }
95
+
96
+ module.exports = { runCommand, startCommand, ikanbanFetch, apiOrigin };
@@ -0,0 +1,217 @@
1
+ // `ikanban pipeline`: tell iKanban where a task is, from any CI (plan 21 F10).
2
+ //
3
+ // ikanban pipeline run --task IKA-1[,IKA-2] --stage build -- <command> [args...]
4
+ // runs the command, prints its output as usual, streams the lines to the task's
5
+ // Pipelines view (secrets masked first), posts running → passed/failed, and exits
6
+ // with the command's own exit code. iKanban being down never fails the command.
7
+ // ikanban pipeline event --task IKA-1 --stage deploy --status passed [--url U] [--sha S] [--detail D]
8
+ // ikanban pipeline log --task IKA-1 --stage build < output.txt
9
+ //
10
+ // Team: --team or IKANBAN_TEAM (identifier like IKA, or its id). Key: VIBE_API_TOKEN.
11
+ // Server: --base-url or IKANBAN_BASE_URL (default https://api.i-kanban.com); the key goes
12
+ // only there (ikanbanFetch).
13
+ const { startCommand, ikanbanFetch, apiOrigin } = require("./ikanban-safe.js");
14
+
15
+ const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY|_KEY$)/i;
16
+ const MAX_CHUNK = 15 * 1024; // the server takes 16 KB per chunk
17
+ const FLUSH_MS = 1000;
18
+
19
+ /** Values to hide: every env var whose name looks secret, plus --mask NAME,NAME. */
20
+ function secretValues(env, extraNames = []) {
21
+ const out = new Set();
22
+ for (const [name, value] of Object.entries(env)) {
23
+ if (!value || value.length < 6) continue;
24
+ if (SECRET_NAME.test(name) || extraNames.includes(name)) out.add(value);
25
+ }
26
+ // Longest first, so a value that contains another is hidden whole.
27
+ return [...out].sort((a, b) => b.length - a.length);
28
+ }
29
+
30
+ function masker(values) {
31
+ return (line) => values.reduce((s, v) => s.split(v).join("***"), line);
32
+ }
33
+
34
+ function parse(args) {
35
+ const opts = { _: [] };
36
+ for (let i = 0; i < args.length; i++) {
37
+ const a = args[i];
38
+ if (a === "--") {
39
+ opts.command = args.slice(i + 1);
40
+ break;
41
+ }
42
+ if (!a.startsWith("--")) opts._.push(a);
43
+ else opts[a.slice(2)] = args[++i];
44
+ }
45
+ return opts;
46
+ }
47
+
48
+ async function api(path, { base, token, method = "GET", body }) {
49
+ const res = await ikanbanFetch(path, { base, token, method, body });
50
+ const data = await res.json().catch(() => ({}));
51
+ if (!res.ok || data.success === false) throw new Error(`HTTP ${res.status}: ${data.message || "refused"}`);
52
+ return data.data;
53
+ }
54
+
55
+ /** The team's id: given as an id, or found by its identifier among the caller's teams. */
56
+ async function teamId(conn, team) {
57
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(team)) return team;
58
+ const teams = await api("/api/teams", conn);
59
+ const t = (teams || []).find((x) => (x.identifier || "").toLowerCase() === team.toLowerCase());
60
+ if (!t) throw new Error(`team ${team} not found among your teams`);
61
+ return t.id;
62
+ }
63
+
64
+ function connection(opts) {
65
+ const token = process.env.VIBE_API_TOKEN || "";
66
+ if (!token) throw new Error("set VIBE_API_TOKEN to an iKanban API key (Settings → API keys)");
67
+ const team = opts.team || process.env.IKANBAN_TEAM || "";
68
+ if (!team) throw new Error("set --team or IKANBAN_TEAM (for example IKA)");
69
+ // F11: each task may name its own run, "IKA-1@pr-159"; otherwise --run (or none).
70
+ const tasks = String(opts.task || process.env.IKANBAN_TASK || "").split(",").map((s) => s.trim()).filter(Boolean)
71
+ .map((t) => {
72
+ const [task, run] = t.split("@");
73
+ return { task, run: run || opts.run || process.env.IKANBAN_RUN || undefined };
74
+ });
75
+ if (!tasks.length) throw new Error("set --task (for example IKA-123; several: IKA-1,IKA-2; with a run: IKA-1@pr-159)");
76
+ if (!opts.stage) throw new Error("set --stage (for example build, deploy, smoke)");
77
+ return {
78
+ base: apiOrigin(opts["base-url"] || process.env.IKANBAN_BASE_URL || "https://api.i-kanban.com"),
79
+ token, team, tasks,
80
+ source: opts.source || process.env.IKANBAN_SOURCE || undefined,
81
+ actor: opts.actor || process.env.IKANBAN_ACTOR || undefined,
82
+ };
83
+ }
84
+
85
+ async function postEvent(conn, tid, t, fields) {
86
+ const body = { task: t.task, run: t.run, source: conn.source, actor: conn.actor, ...fields };
87
+ return api(`/api/teams/${tid}/pipeline-events`, { ...conn, method: "POST", body });
88
+ }
89
+
90
+ /** Sends log text in order, one request at a time; drops lines (once warned) if iKanban is unreachable. */
91
+ function logSender(conn, tid, stage) {
92
+ let queue = "";
93
+ let chain = Promise.resolve();
94
+ let warned = false;
95
+ const send = (text) => {
96
+ chain = chain.then(async () => {
97
+ for (const t of conn.tasks) {
98
+ try {
99
+ await api(`/api/teams/${tid}/pipeline-logs`, { ...conn, method: "POST", body: { task: t.task, run: t.run, stage, text } });
100
+ } catch (e) {
101
+ if (!warned) console.error(`ikanban: log not sent to iKanban (${e.message}); the command goes on`);
102
+ warned = true;
103
+ }
104
+ }
105
+ });
106
+ };
107
+ const flush = () => {
108
+ // Whole lines only, so a secret never splits across two chunks and escapes the mask.
109
+ const cut = queue.lastIndexOf("\n");
110
+ if (cut < 0 && queue.length < MAX_CHUNK) return;
111
+ const take = cut < 0 ? queue.length : cut + 1;
112
+ for (let i = 0; i < take; i += MAX_CHUNK) send(queue.slice(i, Math.min(take, i + MAX_CHUNK)));
113
+ queue = queue.slice(take);
114
+ };
115
+ return {
116
+ push(text) { queue += text; if (queue.length >= MAX_CHUNK) flush(); },
117
+ flush,
118
+ async end() { if (queue) { send(queue); queue = ""; } await chain; },
119
+ };
120
+ }
121
+
122
+ async function runStage(opts) {
123
+ const conn = connection(opts);
124
+ if (!opts.command || !opts.command.length) throw new Error("put the command after --, for example: -- cargo build");
125
+ const tid = await teamId(conn, conn.team);
126
+ const mask = masker(secretValues(process.env, String(opts.mask || "").split(",").filter(Boolean)));
127
+ const started = Date.now();
128
+ const tell = async (status, detail) => {
129
+ for (const t of conn.tasks) {
130
+ try {
131
+ await postEvent(conn, tid, t, { stage: opts.stage, status, url: opts.url, commit_sha: opts.sha, detail });
132
+ } catch (e) {
133
+ console.error(`ikanban: stage ${status} not sent for ${t.task} (${e.message})`);
134
+ }
135
+ }
136
+ };
137
+ await tell("running");
138
+ const log = logSender(conn, tid, opts.stage);
139
+ const timer = setInterval(() => log.flush(), FLUSH_MS);
140
+ const [file, ...args] = opts.command;
141
+ const code = await new Promise((resolve) => {
142
+ let child;
143
+ try {
144
+ child = startCommand(file, args, { stdio: ["inherit", "pipe", "pipe"] });
145
+ } catch (e) {
146
+ console.error(`ikanban: ${e.message}`);
147
+ return resolve(127);
148
+ }
149
+ let partial = "";
150
+ const onData = (stream) => (buf) => {
151
+ stream.write(buf);
152
+ partial += buf.toString("utf8");
153
+ const cut = partial.lastIndexOf("\n");
154
+ if (cut >= 0) {
155
+ log.push(partial.slice(0, cut + 1).split("\n").map(mask).join("\n"));
156
+ partial = partial.slice(cut + 1);
157
+ }
158
+ };
159
+ child.stdout.on("data", onData(process.stdout));
160
+ child.stderr.on("data", onData(process.stderr));
161
+ child.on("error", (e) => { console.error(`ikanban: ${e.message}`); resolve(127); });
162
+ child.on("close", (c) => {
163
+ if (partial) log.push(mask(partial));
164
+ resolve(c === null ? 1 : c);
165
+ });
166
+ });
167
+ clearInterval(timer);
168
+ await log.end();
169
+ const secs = Math.round((Date.now() - started) / 1000);
170
+ await tell(code === 0 ? "passed" : "failed", `${secs} s${code === 0 ? "" : `, exit ${code}`}`);
171
+ return code;
172
+ }
173
+
174
+ async function readStdin() {
175
+ const parts = [];
176
+ for await (const c of process.stdin) parts.push(c);
177
+ return Buffer.concat(parts).toString("utf8");
178
+ }
179
+
180
+ async function run(args) {
181
+ const [sub, ...rest] = args;
182
+ const opts = parse(rest);
183
+ if (sub === "run") return runStage(opts);
184
+ if (sub === "event") {
185
+ const conn = connection(opts);
186
+ if (!opts.status) throw new Error("set --status (queued, running, passed, failed, skipped, held, cancelled)");
187
+ const tid = await teamId(conn, conn.team);
188
+ for (const t of conn.tasks) {
189
+ await postEvent(conn, tid, t, { stage: opts.stage, status: opts.status, url: opts.url, commit_sha: opts.sha, detail: opts.detail });
190
+ }
191
+ console.log(`${opts.stage} ${opts.status}: ${conn.tasks.map((t) => (t.run ? `${t.task}@${t.run}` : t.task)).join(", ")}`);
192
+ return 0;
193
+ }
194
+ if (sub === "log") {
195
+ const conn = connection(opts);
196
+ const tid = await teamId(conn, conn.team);
197
+ const mask = masker(secretValues(process.env, String(opts.mask || "").split(",").filter(Boolean)));
198
+ const log = logSender(conn, tid, opts.stage);
199
+ log.push((await readStdin()).split("\n").map(mask).join("\n"));
200
+ await log.end();
201
+ return 0;
202
+ }
203
+ console.log(HELP);
204
+ return sub ? 2 : 0;
205
+ }
206
+
207
+ const HELP = `Usage:
208
+ ikanban pipeline run --task IKA-1[@pr-159][,IKA-2] --stage build [--url U] [--sha S] [--mask NAME,...] -- <command> [args...]
209
+ ikanban pipeline event --task IKA-1 --stage deploy --status passed [--url U] [--sha S] [--detail D]
210
+ ikanban pipeline log --task IKA-1 --stage build < output.txt
211
+ Every command also takes --run R (the run: a PR like pr-159, or a branch), --source S (where:
212
+ github, gitlab, laptop, ci) and --actor A (who). One run per task: IKA-1@pr-159.
213
+ Team: --team or IKANBAN_TEAM. Key: VIBE_API_TOKEN. Server: --base-url or IKANBAN_BASE_URL.
214
+ Values of environment variables named like a secret (TOKEN, SECRET, PASSWORD, *_KEY…) and of
215
+ --mask names are replaced with *** before a line is sent.`;
216
+
217
+ module.exports = { run, secretValues, masker, parse, logSender, HELP };
package/lib/roadmap.js ADDED
@@ -0,0 +1,280 @@
1
+ // ikanban roadmap sync / ikanban hooks install: keep iKanban's Roadmap in step
2
+ // with the plan file an agent keeps in its repository. No CI: the sync runs from
3
+ // the agent's hook and from git's pre-push hook, on any git host.
4
+ //
5
+ // A plan file opts in with a top-level block:
6
+ // "roadmap": {"team": "<team id or identifier>", "plan_id": "<plan feature id>",
7
+ // "project": "<project id or name for new tasks>", "name": "<plan name>"}
8
+ // plan_id is written back after the first sync creates the plan. Phases:
9
+ // [{id, title, status, depends_on, tasks: [{id, description|title, status}]}]
10
+ const fs = require("node:fs");
11
+ const path = require("node:path");
12
+ const { runCommand, ikanbanFetch, apiOrigin } = require("./ikanban-safe.js");
13
+
14
+ const TASK_STATUS = {
15
+ done: "done", complete: "done", completed: "done",
16
+ in_progress: "inprogress", inprogress: "inprogress", "in-progress": "inprogress", started: "inprogress",
17
+ in_review: "inreview", inreview: "inreview", review: "inreview",
18
+ pending: "todo", todo: "todo", waiting: "todo", deferred: "todo", blocked: "todo",
19
+ cancelled: "cancelled", canceled: "cancelled", skipped: "cancelled", dropped: "cancelled",
20
+ };
21
+ const PHASE_STATUS = { done: "done", in_progress: "in_progress", inprogress: "in_progress", "in-progress": "in_progress" };
22
+
23
+ /** First clause of a task text, cut at a word; no links or email addresses. */
24
+ function short(text, limit = 60) {
25
+ const first = String(text || "").trim().split(/(?<=[a-z0-9)])\. |\s\(|\s—\s/)[0].replace(/[.:]+$/, "");
26
+ let out = "";
27
+ for (const w of first.split(/\s+/).filter((w) => w && !w.includes("://") && !w.includes("@"))) {
28
+ if ((out + " " + w).trim().length > limit) return out + "…";
29
+ out = (out + " " + w).trim();
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** Plan file → the document POST /api/teams/{team}/roadmap/sync takes. */
35
+ function toDocument(plan) {
36
+ const block = plan.roadmap || {};
37
+ const errors = [];
38
+ const phases = (plan.phases || []).map((ph) => {
39
+ const title = String(ph.name || ph.title || "").split(" (")[0].trim();
40
+ const name = title.startsWith(`${ph.id} `) || title === ph.id ? title : `${ph.id} ${title}`.trim();
41
+ const phaseWord = String(ph.status || "").toLowerCase();
42
+ return {
43
+ key: ph.id,
44
+ name,
45
+ depends_on: ph.depends_on || [],
46
+ status: PHASE_STATUS[phaseWord] || (phaseWord ? "waiting" : undefined),
47
+ // Only an explicit "name" renames an existing phase; a name made from the
48
+ // title only names a new one, so a rename on the Roadmap (the pencil) stays.
49
+ rename: Boolean(ph.name),
50
+ tasks: (ph.tasks || []).map((t) => {
51
+ const word = String(t.status || "pending").toLowerCase();
52
+ let status = TASK_STATUS[word];
53
+ if (!status) errors.push(`task ${t.id}: unknown status "${t.status}"`);
54
+ // A deferred task in a phase the plan calls done is out of its scope.
55
+ if (word === "deferred" && phaseWord === "done") status = "cancelled";
56
+ return { key: t.id, title: `${t.id}: ${short(t.title || t.description)}`, status, rename: Boolean(t.title) };
57
+ }),
58
+ };
59
+ });
60
+ if (errors.length) throw new Error(errors.join("; "));
61
+ return {
62
+ plan: { id: block.plan_id || undefined, name: block.name || (block.plan_id ? undefined : plan.title) },
63
+ project: block.project,
64
+ phases,
65
+ };
66
+ }
67
+
68
+ /** .iKanban/settings-local.json (or the older .i-kanban/) nearest to `dir`. */
69
+ function localSettings(dir) {
70
+ for (let d = path.resolve(dir); ; d = path.dirname(d)) {
71
+ for (const folder of [".iKanban", ".i-kanban"]) {
72
+ const f = path.join(d, folder, "settings-local.json");
73
+ if (fs.existsSync(f)) {
74
+ try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return {}; }
75
+ }
76
+ }
77
+ if (path.dirname(d) === d) return {};
78
+ }
79
+ }
80
+
81
+ // The key only ever goes to iKanban's own API (or this machine, for a local
82
+ // server): never to an address from a plan file, a repository's settings file or
83
+ // an environment variable that names another host.
84
+
85
+ function connection(opts, file) {
86
+ const s = localSettings(path.dirname(file));
87
+ return {
88
+ base: apiOrigin(opts["base-url"] || process.env.IKANBAN_BASE_URL || s.api_url || "https://api.i-kanban.com"),
89
+ token: opts.token || process.env.VIBE_API_TOKEN || s.api_key || "",
90
+ team: process.env.IKANBAN_TEAM_ID || s.team || "",
91
+ project: s.project,
92
+ };
93
+ }
94
+
95
+ /** Adds "plan_id" to the roadmap block as text, so the file keeps its layout. */
96
+ function writePlanId(file, id) {
97
+ const text = fs.readFileSync(file, "utf8");
98
+ const next = text.replace(/("roadmap"\s*:\s*\{)(\s*)/, (_, open, space) => `${open}${space}"plan_id": "${id}",${space || " "}`);
99
+ if (next !== text && JSON.parse(next).roadmap.plan_id === id) fs.writeFileSync(file, next);
100
+ }
101
+
102
+ async function syncFile(file, opts) {
103
+ const plan = JSON.parse(fs.readFileSync(file, "utf8"));
104
+ if (!plan.roadmap) {
105
+ console.log(`${file}: no "roadmap" block, skipped`);
106
+ return 0;
107
+ }
108
+ const c = connection(opts, file);
109
+ const team = plan.roadmap.team || c.team;
110
+ if (!c.token) throw new Error("no API key: set VIBE_API_TOKEN, pass --token, or set api_key in .iKanban/settings-local.json");
111
+ if (!team) throw new Error('no team: add "team" to the roadmap block or .iKanban/settings-local.json');
112
+ const doc = toDocument(plan);
113
+ doc.project = doc.project || c.project;
114
+ doc.dry_run = Boolean(opts.check || opts["dry-run"]);
115
+ // Deleting an empty phase the sync created needs an explicit flag; MCP never deletes.
116
+ if (opts["allow-delete"]) doc.allow_delete = true;
117
+ let res;
118
+ try {
119
+ // The shared helper sends the key only to iKanban's API or this machine, never on a redirect.
120
+ res = await ikanbanFetch(`/api/teams/${encodeURIComponent(team)}/roadmap/sync`, {
121
+ base: c.base, token: c.token, method: "POST", body: doc,
122
+ });
123
+ } catch (e) {
124
+ throw new Error(`${e.message}; the Roadmap was not changed`);
125
+ }
126
+ const body = await res.json().catch(() => ({}));
127
+ if (!res.ok || body.success === false) throw new Error(`HTTP ${res.status}: ${body.message || "sync refused"}`);
128
+ const out = body.data;
129
+ const drift = out.changes.filter((ch) => !ch.action.startsWith("adopt_"));
130
+ for (const ch of drift) {
131
+ const what = ch.name || ch.title || (ch.to ? `${ch.from} → ${ch.to}` : "") || (ch.depends_on ? `after ${ch.depends_on.join(", ") || "nothing"}` : "");
132
+ console.log(` ${ch.action} ${ch.key || ""} ${what}`.trimEnd());
133
+ }
134
+ for (const w of out.warnings) console.log(` warning: ${w}`);
135
+ const verb = out.applied ? `${drift.length} change(s) applied` : out.in_sync ? "in sync" : `${drift.length} difference(s)`;
136
+ console.log(`${path.basename(file)} → ${out.plan_name || "plan"}: ${verb}`);
137
+ if (out.applied && out.plan_id && !plan.roadmap.plan_id) {
138
+ writePlanId(file, out.plan_id);
139
+ console.log(` plan_id ${out.plan_id} written to the roadmap block`);
140
+ }
141
+ return opts.check && !out.in_sync ? 1 : 0;
142
+ }
143
+
144
+ /** Tracked JSON files with a top-level "roadmap" block in this git repository. */
145
+ function registeredPlans(cwd) {
146
+ const r = runCommand("git", ["grep", "-l", '"roadmap"', "--", "*.json"], { cwd, encoding: "utf8" });
147
+ return (r.stdout || "").split("\n").filter(Boolean).map((f) => path.join(cwd, f)).filter((f) => {
148
+ try { return Boolean(JSON.parse(fs.readFileSync(f, "utf8")).roadmap); } catch { return false; }
149
+ });
150
+ }
151
+
152
+ const HOOK_MARK = "# ikanban roadmap sync";
153
+ const PRE_PUSH = `${HOOK_MARK} (added by "ikanban hooks install"; never blocks the push)
154
+ if command -v npx >/dev/null 2>&1; then
155
+ npx -y ikanban roadmap sync --quiet-errors || true
156
+ fi
157
+ `;
158
+ const AGENT_COMMAND = "node .iKanban/hooks/roadmap-sync.js";
159
+
160
+ function mergeJson(file, update) {
161
+ const cur = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
162
+ const next = update(cur);
163
+ fs.mkdirSync(path.dirname(file), { recursive: true });
164
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
165
+ }
166
+
167
+ /** Adds one entry to hooks[event] unless an entry already runs our command. */
168
+ function addHook(settings, event, entry) {
169
+ settings.hooks = settings.hooks || {};
170
+ const list = (settings.hooks[event] = settings.hooks[event] || []);
171
+ if (!JSON.stringify(list).includes("roadmap-sync.js")) list.push(entry);
172
+ return settings;
173
+ }
174
+
175
+ const AGENTS = {
176
+ claude: (root) => {
177
+ mergeJson(path.join(root, ".claude", "settings.json"), (s) =>
178
+ addHook(s, "PostToolUse", { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: AGENT_COMMAND, timeout: 90 }] }));
179
+ return ".claude/settings.json (PostToolUse)";
180
+ },
181
+ gemini: (root) => {
182
+ mergeJson(path.join(root, ".gemini", "settings.json"), (s) =>
183
+ addHook(s, "AfterTool", { matcher: "write_file|replace", hooks: [{ name: "ikanban-roadmap-sync", type: "command", command: AGENT_COMMAND, timeout: 90000 }] }));
184
+ return ".gemini/settings.json (AfterTool)";
185
+ },
186
+ copilot: (root) => {
187
+ const file = path.join(root, ".github", "hooks", "ikanban-roadmap.json");
188
+ mergeJson(file, (s) => {
189
+ s.version = 1;
190
+ return addHook(s, "postToolUse", { type: "command", bash: AGENT_COMMAND, powershell: AGENT_COMMAND, cwd: ".", timeoutSec: 90 });
191
+ });
192
+ return ".github/hooks/ikanban-roadmap.json (postToolUse)";
193
+ },
194
+ };
195
+
196
+ function installHooks(opts) {
197
+ const root = runCommand("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).stdout.trim();
198
+ if (!root) throw new Error("run this inside a git repository");
199
+ const done = [];
200
+ // The agent hook script ships in .iKanban/hooks/; this package carries a copy.
201
+ const script = path.join(root, ".iKanban", "hooks", "roadmap-sync.js");
202
+ fs.mkdirSync(path.dirname(script), { recursive: true });
203
+ // The hook runs its commands through ikanban-safe.js, so both files go together.
204
+ for (const name of ["roadmap-sync.js", "ikanban-safe.js"]) {
205
+ fs.copyFileSync(path.join(__dirname, "..", "hooks", name), path.join(path.dirname(script), name));
206
+ done.push(`.iKanban/hooks/${name}`);
207
+ }
208
+
209
+ const hooksDir = runCommand("git", ["rev-parse", "--git-path", "hooks"], { cwd: root, encoding: "utf8" }).stdout.trim();
210
+ const prePush = path.resolve(root, hooksDir, "pre-push");
211
+ const cur = fs.existsSync(prePush) ? fs.readFileSync(prePush, "utf8") : "#!/bin/sh\n";
212
+ if (!cur.includes(HOOK_MARK)) {
213
+ fs.mkdirSync(path.dirname(prePush), { recursive: true });
214
+ fs.writeFileSync(prePush, `${cur.replace(/\n*$/, "\n")}\n${PRE_PUSH}`);
215
+ }
216
+ fs.chmodSync(prePush, 0o755);
217
+ done.push("git pre-push hook");
218
+
219
+ const wanted = opts.agent ? String(opts.agent).split(",") : Object.keys(AGENTS).filter((a) =>
220
+ fs.existsSync(path.join(root, { claude: ".claude", gemini: ".gemini", copilot: ".github" }[a])));
221
+ for (const a of wanted) {
222
+ if (!AGENTS[a]) throw new Error(`unknown agent "${a}" (claude, gemini, copilot)`);
223
+ done.push(AGENTS[a](root));
224
+ }
225
+ for (const d of done) console.log(` installed: ${d}`);
226
+ console.log("Plan files (JSON with a top-level \"roadmap\" block) now reach the Roadmap on every agent write and every git push.");
227
+ return 0;
228
+ }
229
+
230
+ const HELP = `Usage:
231
+ ikanban roadmap sync [plan.json ...] [--check] [--dry-run] [--allow-delete] [--token T] [--base-url URL]
232
+ Make each plan's Roadmap match the file. No file: every tracked JSON file
233
+ with a top-level "roadmap" block. --check: change nothing, exit 1 on drift.
234
+ --allow-delete: also delete an emptied phase the sync created (else it stays).
235
+ ikanban hooks install [--agent claude,gemini,copilot]
236
+ Keep the Roadmap in step without CI: a git pre-push hook (any git host) and
237
+ a hook for each agent whose folder exists (.claude, .gemini, .github).
238
+
239
+ Key: VIBE_API_TOKEN, --token, or api_key in .iKanban/settings-local.json.
240
+ Server: IKANBAN_BASE_URL, --base-url, or api_url there (default https://api.i-kanban.com).`;
241
+
242
+ function parse(args) {
243
+ const opts = { _: [] };
244
+ for (let i = 0; i < args.length; i++) {
245
+ const a = args[i];
246
+ if (!a.startsWith("--")) opts._.push(a);
247
+ else if (["--token", "--base-url", "--agent"].includes(a)) opts[a.slice(2)] = args[++i];
248
+ else opts[a.slice(2)] = true;
249
+ }
250
+ return opts;
251
+ }
252
+
253
+ async function run(cmd, args) {
254
+ const opts = parse(args);
255
+ const [sub, ...files] = opts._;
256
+ if (opts.help || !sub) {
257
+ console.log(HELP);
258
+ return 0;
259
+ }
260
+ if (cmd === "hooks" && sub === "install") return installHooks(opts);
261
+ if (cmd !== "roadmap" || sub !== "sync") {
262
+ console.error(`ikanban: unknown command "${cmd} ${sub}". Run "ikanban roadmap --help".`);
263
+ return 2;
264
+ }
265
+ const list = files.length ? files.map((f) => path.resolve(f)) : registeredPlans(process.cwd());
266
+ if (!list.length) console.log('No plan files: add a top-level "roadmap" block to a plan JSON file.');
267
+ let code = 0;
268
+ for (const f of list) {
269
+ try {
270
+ code = Math.max(code, await syncFile(f, opts));
271
+ } catch (e) {
272
+ console.error(`${path.basename(f)}: ${e.message}`);
273
+ // From a git hook, an error never blocks the push.
274
+ if (!opts["quiet-errors"]) code = Math.max(code, 1);
275
+ }
276
+ }
277
+ return code;
278
+ }
279
+
280
+ module.exports = { run, toDocument, short, writePlanId, addHook, apiOrigin };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "ikanban",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "iKanban CLI: AI and security scans of your own repo, with results filed into iKanban",
5
5
  "bin": { "ikanban": "bin/ikanban.js" },
6
- "files": ["bin", "scanners", "README.md"],
6
+ "files": ["bin", "lib", "hooks", "scanners", "README.md"],
7
7
  "scripts": {
8
8
  "prepack": "node scripts/copy-scanners.js",
9
9
  "pretest": "node scripts/copy-scanners.js",
10
- "test": "node --test test/cli.test.js"
10
+ "test": "node --test test/cli.test.js test/safe.test.js test/roadmap.test.js test/pipeline.test.js"
11
11
  },
12
12
  "engines": { "node": ">=18" },
13
13
  "homepage": "https://i-kanban.com",
@@ -44,6 +44,7 @@ import subprocess
44
44
  import sys
45
45
  import tempfile
46
46
  import urllib.error
47
+ import urllib.parse
47
48
  import urllib.request
48
49
  import uuid
49
50
  from datetime import datetime, timezone
@@ -56,7 +57,7 @@ PROMPTFOO_VERSION = "0.123.1"
56
57
  # so this string is the release identity. Bump it when publishing a release —
57
58
  # a customer pinning a version is pinning this value, and re-uploading different
58
59
  # bytes under a version someone already fetched defeats the sha256 they pinned.
59
- AISCAN_VERSION = "0.3.1"
60
+ AISCAN_VERSION = "0.3.2"
60
61
 
61
62
  # The ten categories, their labels, and their plugins -- the forward map, copied
62
63
  # from promptfoo's OWASP_LLM_TOP_10_MAPPING (src/redteam/constants/frameworks.ts).
@@ -291,7 +292,8 @@ def build_config(target, spec, plugins, num_tests, base_url, token, workspace_id
291
292
  "server": {
292
293
  "url": spec["mcp_url"],
293
294
  "name": target,
294
- "auth": {"type": "bearer", "token": spec.get("bearer") or token},
295
+ # An MCP server from aiscan.toml gets only its own auth, never the iKanban key (T3).
296
+ "auth": {"type": "bearer", "token": spec.get("bearer") or ("" if spec.get("from_config") else token)},
295
297
  },
296
298
  },
297
299
  }
@@ -597,6 +599,30 @@ machine against the target named above.</footer>"""
597
599
  Path(path).write_text(doc, encoding="utf-8")
598
600
 
599
601
 
602
+ # Coding standard T3: the iKanban key goes only to iKanban's API (or this machine), and never
603
+ # on a redirect. A --base-url, IKANBAN_BASE_URL or config file cannot send it anywhere else.
604
+ IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
605
+
606
+
607
+ def ikanban_api(base_url):
608
+ """The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
609
+ u = urllib.parse.urlsplit(base_url or "")
610
+ origin = f"{u.scheme}://{u.netloc}"
611
+ if u.username is None and u.path in ("", "/") and not u.query and (
612
+ origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
613
+ return origin
614
+ raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
615
+ f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
616
+
617
+
618
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
619
+ def redirect_request(self, *args, **kwargs):
620
+ return None # a 3xx comes back as an HTTPError, and the key goes nowhere else
621
+
622
+
623
+ _OPENER = urllib.request.build_opener(_NoRedirect)
624
+
625
+
600
626
  def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results):
601
627
  payload = {
602
628
  "scan_run_id": run_id,
@@ -611,13 +637,13 @@ def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results
611
637
  "plugin_results": plugin_results,
612
638
  }
613
639
  req = urllib.request.Request(
614
- f"{base_url.rstrip('/')}/api/teams/{team_id}/security/ai-seed",
640
+ f"{ikanban_api(base_url)}/api/teams/{team_id}/security/ai-seed",
615
641
  data=json.dumps(payload).encode(),
616
642
  headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
617
643
  method="POST",
618
644
  )
619
645
  try:
620
- with urllib.request.urlopen(req, timeout=30) as resp:
646
+ with _OPENER.open(req, timeout=30) as resp:
621
647
  body = json.loads(resp.read().decode())
622
648
  data = body.get("data", body)
623
649
  # plugin_results_stored is absent on a backend that predates
@@ -710,6 +736,10 @@ def main(argv=None):
710
736
  % (args.target, ", ".join(sorted(targets)))
711
737
  )
712
738
  spec = targets[args.target]
739
+ # Before the scan, not after it: the key goes to base_url when publishing and for the
740
+ # builtin authenticated targets, so it must be iKanban's API (T3).
741
+ if args.token and (args.publish or (spec.get("auth") and not spec.get("from_config"))):
742
+ args.base_url = ikanban_api(args.base_url)
713
743
 
714
744
  if shutil.which("npx") is None:
715
745
  log("error: npx not found. promptfoo needs Node.js — https://nodejs.org")
@@ -54,6 +54,7 @@ import stat
54
54
  import subprocess
55
55
  import sys
56
56
  import urllib.error
57
+ import urllib.parse
57
58
  import urllib.request
58
59
  from dataclasses import dataclass, field
59
60
  from datetime import datetime, timezone
@@ -63,7 +64,7 @@ from pathlib import Path
63
64
  # that npm no longer hosts the package. Starting at 1.0.0 rather than continuing
64
65
  # the npm package's 0.1.x line: that counter belonged to the wrapper, not to
65
66
  # this file, and reusing it would suggest a continuity that does not exist.
66
- SECSCAN_VERSION = "1.2.0"
67
+ SECSCAN_VERSION = "1.2.1"
67
68
 
68
69
  SEVERITIES = ["critical", "high", "medium", "low", "info"]
69
70
  SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
@@ -674,17 +675,41 @@ def build_findings_payload(sections: list, home: str | None = None) -> tuple[lis
674
675
  OLD_SERVER = "this iKanban server does not take them yet; the report is filed"
675
676
 
676
677
 
678
+ # Coding standard T3: the iKanban key goes only to iKanban's API (or this machine), and never
679
+ # on a redirect. A --base-url, IKANBAN_BASE_URL or config file cannot send it anywhere else.
680
+ IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
681
+
682
+
683
+ def ikanban_api(base_url):
684
+ """The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
685
+ u = urllib.parse.urlsplit(base_url or "")
686
+ origin = f"{u.scheme}://{u.netloc}"
687
+ if u.username is None and u.path in ("", "/") and not u.query and (
688
+ origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
689
+ return origin
690
+ raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
691
+ f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
692
+
693
+
694
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
695
+ def redirect_request(self, *args, **kwargs):
696
+ return None # a 3xx comes back as an HTTPError, and the key goes nowhere else
697
+
698
+
699
+ _OPENER = urllib.request.build_opener(_NoRedirect)
700
+
701
+
677
702
  def api(method: str, path: str, base_url: str, token: str, payload=None):
678
703
  """(ok, data | error text | None for 404)."""
679
704
  req = urllib.request.Request(
680
- f"{base_url.rstrip('/')}/api{path}",
705
+ f"{ikanban_api(base_url)}/api{path}",
681
706
  data=json.dumps(payload).encode() if payload is not None else None,
682
707
  headers={"Authorization": f"Bearer {token}",
683
708
  "Content-Type": "application/json"},
684
709
  method=method,
685
710
  )
686
711
  try:
687
- with urllib.request.urlopen(req, timeout=30) as r:
712
+ with _OPENER.open(req, timeout=30) as r:
688
713
  body = json.loads(r.read().decode() or "{}")
689
714
  return True, body.get("data", body)
690
715
  except urllib.error.HTTPError as e:
@@ -757,14 +782,14 @@ def publish(md: str, title: str, base_url: str, token: str, team_id: str,
757
782
  # workspace has an active subscription. Scanning stays free; only filing the
758
783
  # report into iKanban is a paid feature.
759
784
  req = urllib.request.Request(
760
- f"{base_url.rstrip('/')}/api/security/scan-reports",
785
+ f"{ikanban_api(base_url)}/api/security/scan-reports",
761
786
  data=json.dumps(payload).encode(),
762
787
  headers={"Authorization": f"Bearer {token}",
763
788
  "Content-Type": "application/json"},
764
789
  method="POST",
765
790
  )
766
791
  try:
767
- with urllib.request.urlopen(req, timeout=30) as r:
792
+ with _OPENER.open(req, timeout=30) as r:
768
793
  body = json.loads(r.read().decode() or "{}")
769
794
  doc = body.get("data", body)
770
795
  return True, doc.get("id", "")