pi-kanban 0.3.1 → 2.0.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 +30 -0
- package/README.md +61 -21
- package/cli.js +1586 -0
- package/extensions/kanban.ts +330 -367
- package/extensions/package.json +1 -0
- package/extensions/tools/ask.ts +85 -0
- package/extensions/tools/board-api.ts +142 -0
- package/extensions/tools/cli-path.ts +34 -0
- package/extensions/tools/dispatch.ts +56 -0
- package/extensions/tools/inbox.ts +86 -0
- package/extensions/tools/kanban-command.ts +27 -0
- package/extensions/tools/scratchpad.ts +70 -0
- package/extensions/tsconfig.json +16 -0
- package/lib/agent-dir.js +20 -0
- package/lib/agent-log.js +64 -0
- package/lib/ask.js +252 -0
- package/lib/atomic-write.js +30 -0
- package/lib/board-dir.js +63 -0
- package/lib/claude-settings.js +21 -0
- package/lib/companion-host.js +142 -0
- package/lib/companions.js +136 -0
- package/lib/contain.js +78 -0
- package/lib/dispatch-reports.js +86 -0
- package/lib/file-url.js +38 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/frame-policy.js +79 -0
- package/lib/git-branch.js +37 -102
- package/lib/http-error.js +7 -0
- package/lib/inline-assets.js +238 -0
- package/lib/linked-docs.js +56 -0
- package/lib/live-sessions.js +86 -0
- package/lib/model-windows.js +48 -0
- package/lib/net-guard.js +277 -0
- package/lib/one-line.js +6 -0
- package/lib/open-editor.js +173 -0
- package/lib/panes.js +213 -0
- package/lib/parsers.js +1434 -0
- package/lib/pi-agents.js +178 -0
- package/lib/pi-git-branch.js +112 -0
- package/lib/pi-models.js +34 -0
- package/lib/pi-sessions.js +240 -0
- package/lib/pi-transcript.js +354 -0
- package/lib/preview-action.js +55 -0
- package/lib/priority.js +45 -0
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +191 -0
- package/lib/scratch-files.js +47 -0
- package/lib/scratchpad-dir.js +95 -0
- package/lib/session-cache.js +32 -0
- package/lib/session-events.js +275 -0
- package/lib/session-pins.js +99 -0
- package/lib/show.js +279 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/task-counts.js +90 -0
- package/lib/task-dir.js +26 -0
- package/lib/task-store.js +44 -47
- package/lib/terminal-client.js +248 -0
- package/lib/terminal-host.js +113 -0
- package/lib/terminal.js +1116 -0
- package/lib/user-groups.js +384 -0
- package/lib/worktrees.js +132 -0
- package/package.json +39 -15
- package/public/app.js +14349 -3448
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/group-tree.js +21 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +7 -5
- package/public/index.html +493 -328
- package/public/link-url.js +18 -0
- package/public/manifest.json +7 -7
- package/public/project-match.js +34 -0
- package/public/style.css +8301 -2251
- package/public/sw.js +4 -3
- package/public/terminal-frame.js +437 -0
- package/public/terminal.html +26 -0
- package/public/themes.css +876 -0
- package/public/tree-highlight.js +196 -0
- package/server.js +3368 -467
- package/skills/ask/SKILL.md +29 -0
- package/skills/ask/references/playground.md +25 -0
- package/skills/dispatch/SKILL.md +51 -0
- package/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/skills/kanban/SKILL.md +46 -0
- package/skills/show/SKILL.md +41 -0
- package/skills/show/references/NOTICE.md +27 -0
- package/skills/show/references/chart.md +58 -0
- package/skills/show/references/diagram/architecture.md +18 -0
- package/skills/show/references/diagram/er.md +10 -0
- package/skills/show/references/diagram/flow.md +13 -0
- package/skills/show/references/diagram/layers.md +10 -0
- package/skills/show/references/diagram/sequence.md +19 -0
- package/skills/show/references/diagram/state.md +12 -0
- package/skills/show/references/diagram/swimlane.md +10 -0
- package/skills/show/references/diagram/timeline.md +9 -0
- package/skills/show/references/diagram/tree.md +10 -0
- package/skills/show/references/diagram.md +87 -0
- package/skills/show/references/explain.md +100 -0
- package/skills/show/references/html-contract.md +18 -0
- package/skills/show/references/mockup.md +30 -0
- package/skills/visualize/SKILL.md +73 -0
- package/skills/visualize/blocks.html +68 -0
- package/skills/visualize/template.html +179 -0
- package/lib/pi-parsers.js +0 -1000
- package/themes/kanban-dark-default.json +0 -21
- package/themes/kanban-default.json +0 -21
- package/themes/pi-dark.json +0 -21
- package/themes/pi-light.json +0 -21
package/lib/contain.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Path containment check.
|
|
4
|
+
//
|
|
5
|
+
// The obvious implementation — `child.startsWith(root)` — is wrong twice over:
|
|
6
|
+
// it accepts a sibling directory that merely shares a prefix ("/plugins/foo-evil"
|
|
7
|
+
// passes a check for "/plugins/foo"), and it is blind to symlinks, `..`, casing
|
|
8
|
+
// and Windows 8.3 short names. This uses path.relative instead, and canonicalises
|
|
9
|
+
// both sides first.
|
|
10
|
+
//
|
|
11
|
+
// Forked from scripts/security-lib/contain.js in the claude-code-hub repo; this copy is
|
|
12
|
+
// maintained here.
|
|
13
|
+
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
|
|
17
|
+
const IS_WIN = process.platform === 'win32';
|
|
18
|
+
|
|
19
|
+
// realpath the deepest existing ancestor, then re-append the non-existent tail,
|
|
20
|
+
// so a not-yet-created file under a symlinked directory still canonicalises.
|
|
21
|
+
function realpathDeepest(target) {
|
|
22
|
+
let cur = target;
|
|
23
|
+
const tail = [];
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
const real = fs.realpathSync.native(cur);
|
|
27
|
+
return tail.length ? path.join(real, ...tail.reverse()) : real;
|
|
28
|
+
} catch {
|
|
29
|
+
const parent = path.dirname(cur);
|
|
30
|
+
if (parent === cur) return target; // reached the volume root, nothing resolvable
|
|
31
|
+
tail.push(path.basename(cur));
|
|
32
|
+
cur = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonical(target, deepest) {
|
|
38
|
+
let out = path.resolve(target);
|
|
39
|
+
if (deepest) out = realpathDeepest(out);
|
|
40
|
+
else { try { out = fs.realpathSync.native(out); } catch { /* may not exist yet */ } }
|
|
41
|
+
// realpathSync.native has already expanded 8.3 short names (PROGRA~1) and fixed
|
|
42
|
+
// the casing of the existing portion; lowercase covers the rest.
|
|
43
|
+
return IS_WIN ? out.toLowerCase() : out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function beneath(canonicalChild, root) {
|
|
47
|
+
const rel = path.relative(canonical(root, false), canonicalChild);
|
|
48
|
+
// '' means child === root. An absolute rel means a different drive or UNC share,
|
|
49
|
+
// which path.relative signals by giving up — reject those.
|
|
50
|
+
if (rel === '') return true;
|
|
51
|
+
if (path.isAbsolute(rel)) return false;
|
|
52
|
+
return rel !== '..' && !rel.startsWith(`..${path.sep}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True iff `child` is `root` itself or lies beneath it.
|
|
57
|
+
* @param {string} child
|
|
58
|
+
* @param {string} root
|
|
59
|
+
*/
|
|
60
|
+
function isContained(child, root) {
|
|
61
|
+
if (typeof child !== 'string' || typeof root !== 'string' || !child || !root) return false;
|
|
62
|
+
return beneath(canonical(child, true), root);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True iff `child` is contained by any of `roots`. Canonicalises the child once
|
|
67
|
+
* instead of once per root — callers testing a single path against several roots
|
|
68
|
+
* were paying that walk repeatedly.
|
|
69
|
+
* @param {string} child
|
|
70
|
+
* @param {string[]} roots
|
|
71
|
+
*/
|
|
72
|
+
function isContainedAny(child, roots) {
|
|
73
|
+
if (typeof child !== 'string' || !child || !Array.isArray(roots)) return false;
|
|
74
|
+
const c = canonical(child, true);
|
|
75
|
+
return roots.some((r) => typeof r === 'string' && r && beneath(c, r));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { isContained, isContainedAny, realpathDeepest };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A dispatched session's report back to the session that started it. The worker posts it once it
|
|
4
|
+
// is done; the starter's call long-polls for it. The report lives on the dispatch marker, so it
|
|
5
|
+
// waits there when the starter polls late or the board restarts in between.
|
|
6
|
+
|
|
7
|
+
const MAX_REPORT = 16 * 1024;
|
|
8
|
+
const WAIT_MAX_MS = 25_000;
|
|
9
|
+
|
|
10
|
+
function parseReport(body) {
|
|
11
|
+
const text = body?.text;
|
|
12
|
+
if (typeof text !== 'string' || !text.trim()) return { error: 'text is required' };
|
|
13
|
+
if (text.length > MAX_REPORT) return { error: `text must be at most ${MAX_REPORT} characters` };
|
|
14
|
+
return { text: text.trim() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} o
|
|
19
|
+
* @param {ReturnType<import('./retention').createDispatchedStore>} o.dispatched
|
|
20
|
+
* @param {(session: string) => boolean} o.isRunning the session's terminal still runs
|
|
21
|
+
*/
|
|
22
|
+
function createDispatchReports({ dispatched, isRunning }) {
|
|
23
|
+
const waiters = new Map();
|
|
24
|
+
|
|
25
|
+
// Null for a session that was not dispatched.
|
|
26
|
+
function status(id) {
|
|
27
|
+
const marker = dispatched.get(id);
|
|
28
|
+
if (!marker) return null;
|
|
29
|
+
if (marker.report) return { status: 'reported', ...marker.report };
|
|
30
|
+
return isRunning(id) ? { status: 'pending' } : { status: 'ended' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Wakes the waiters of a session to read its status again: a report came, or its terminal ended.
|
|
34
|
+
function notify(id) {
|
|
35
|
+
const set = waiters.get(id);
|
|
36
|
+
if (!set) return;
|
|
37
|
+
waiters.delete(id);
|
|
38
|
+
for (const wake of set) wake();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function post(id, text) {
|
|
42
|
+
const report = dispatched.setReport(id, text);
|
|
43
|
+
if (report) notify(id);
|
|
44
|
+
return report;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function wait(id, ms) {
|
|
48
|
+
const now = status(id);
|
|
49
|
+
if (!now || now.status !== 'pending' || ms <= 0) return now;
|
|
50
|
+
await new Promise((resolve) => {
|
|
51
|
+
const wake = () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
resolve();
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
const set = waiters.get(id);
|
|
57
|
+
set?.delete(wake);
|
|
58
|
+
if (set && !set.size) waiters.delete(id);
|
|
59
|
+
resolve();
|
|
60
|
+
}, Math.min(ms, WAIT_MAX_MS));
|
|
61
|
+
timer.unref?.();
|
|
62
|
+
if (!waiters.has(id)) waiters.set(id, new Set());
|
|
63
|
+
waiters.get(id).add(wake);
|
|
64
|
+
});
|
|
65
|
+
return status(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { status, post, wait, notify };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Shared by the CLI and the pi tools, so both print and match dispatches the same way.
|
|
72
|
+
function dispatchLine(d) {
|
|
73
|
+
return [d.session, d.name, d.group && `[${d.group}]`, d.cwd, d.reportedAt && '(reported)'].filter(Boolean).join(' ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function matchDispatches(rows, query) {
|
|
77
|
+
return rows.filter((r) => r.name === query || r.session.startsWith(query));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function reportText(session, r) {
|
|
81
|
+
if (r.status === 'reported') return `Report from ${session}:\n${r.text}`;
|
|
82
|
+
if (r.status === 'ended') return `The terminal of ${session} ended without a report.`;
|
|
83
|
+
return `${session} has not reported yet.`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { createDispatchReports, parseReport, dispatchLine, matchDispatches, reportText, MAX_REPORT, WAIT_MAX_MS };
|
package/lib/file-url.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Some tools hand out a `file://` URL where a path is expected — Explorer's
|
|
4
|
+
// "copy as path" on a network share, a PDF viewer's location bar, a chat client
|
|
5
|
+
// that linkifies attachments. Windows apps also emit the non-standard backslash
|
|
6
|
+
// spelling `file:\\\C:\dir\a.md`. Both are normalized to a plain OS path so the
|
|
7
|
+
// user does not have to hand-edit what their clipboard produced.
|
|
8
|
+
|
|
9
|
+
const { fileURLToPath } = require('node:url');
|
|
10
|
+
|
|
11
|
+
// Matches any number of leading slashes before a drive letter, so it re-spells both
|
|
12
|
+
// the well-formed `file:///C:/dir` and `file://C:/dir`, where the parser reads C: as
|
|
13
|
+
// a host and rejects the URL outright. Anything else — POSIX paths, UNC shares — is
|
|
14
|
+
// left for the parser.
|
|
15
|
+
const DRIVE_URL = /^file:\/*([a-zA-Z]:.*)$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert a `file://` URL to an OS path. Any other string is returned unchanged,
|
|
19
|
+
* as is a URL too malformed to parse — the caller reports "not found" on the
|
|
20
|
+
* literal text, which is more useful than a parser error.
|
|
21
|
+
* @param {string} value
|
|
22
|
+
*/
|
|
23
|
+
function fileUrlToPath(value) {
|
|
24
|
+
if (typeof value !== 'string' || !/^file:/i.test(value)) return value;
|
|
25
|
+
// Backslashes are not legal in a URL, so this cannot corrupt a well-formed one.
|
|
26
|
+
let url = value.replace(/\\/g, '/');
|
|
27
|
+
// A scheme with no path body: POSIX Node resolves it to "/" rather than throwing.
|
|
28
|
+
if (/^file:\/*$/i.test(url)) return value;
|
|
29
|
+
const drive = DRIVE_URL.exec(url);
|
|
30
|
+
if (drive) url = `file:///${drive[1]}`;
|
|
31
|
+
try {
|
|
32
|
+
return fileURLToPath(url);
|
|
33
|
+
} catch {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { fileUrlToPath };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Opens the OS folder picker on the machine running pi-kanban. The page cannot do this: a
|
|
4
|
+
// browser's directory input hands over file contents, never the folder's path.
|
|
5
|
+
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
7
|
+
const { statSync } = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const PROMPT = 'Choose a folder for the new session';
|
|
11
|
+
|
|
12
|
+
// The owner form keeps the dialog above the browser; without it the dialog opens behind.
|
|
13
|
+
// The start folder arrives in an env var, never spliced into the script.
|
|
14
|
+
const WIN_SCRIPT = [
|
|
15
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
16
|
+
'[Console]::OutputEncoding = [Text.Encoding]::UTF8',
|
|
17
|
+
'$owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true }',
|
|
18
|
+
'$d = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
19
|
+
`$d.Description = "${PROMPT}"`,
|
|
20
|
+
'$d.ShowNewFolderButton = $true',
|
|
21
|
+
'if ($env:PK_PICK_START) { $d.SelectedPath = $env:PK_PICK_START }',
|
|
22
|
+
'if ($d.ShowDialog($owner) -eq "OK") { [Console]::Out.Write($d.SelectedPath) }',
|
|
23
|
+
].join('; ');
|
|
24
|
+
|
|
25
|
+
// argv, not string interpolation, so a quote in the path cannot end the AppleScript literal.
|
|
26
|
+
const MAC_SCRIPT = [
|
|
27
|
+
'on run argv',
|
|
28
|
+
'if (count of argv) > 0 then',
|
|
29
|
+
`return POSIX path of (choose folder with prompt "${PROMPT}" default location (POSIX file (item 1 of argv)))`,
|
|
30
|
+
'end if',
|
|
31
|
+
`return POSIX path of (choose folder with prompt "${PROMPT}")`,
|
|
32
|
+
'end run',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function candidates(platform, start) {
|
|
36
|
+
// No `.exe`: whichSync appends PATHEXT itself, so `powershell.exe` is never found.
|
|
37
|
+
// pwsh first: on .NET Core the same FolderBrowserDialog is the Explorer-style picker, while
|
|
38
|
+
// Windows PowerShell (.NET Framework) shows the old tree view.
|
|
39
|
+
if (platform === 'win32') {
|
|
40
|
+
const args = ['-NoProfile', '-STA', '-Command', WIN_SCRIPT];
|
|
41
|
+
return [['pwsh', args], ['powershell', args]];
|
|
42
|
+
}
|
|
43
|
+
if (platform === 'darwin') return [['osascript', [...MAC_SCRIPT.flatMap((l) => ['-e', l]), ...(start ? [start] : [])]]];
|
|
44
|
+
return [
|
|
45
|
+
['zenity', ['--file-selection', '--directory', `--title=${PROMPT}`, ...(start ? [`--filename=${start}${path.sep}`] : [])]],
|
|
46
|
+
['kdialog', ['--getexistingdirectory', start || '.', '--title', PROMPT]],
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The page sends any text the user typed; only an existing absolute folder is worth opening at.
|
|
51
|
+
function startFolder(dir) {
|
|
52
|
+
if (typeof dir !== 'string' || !path.isAbsolute(dir)) return null;
|
|
53
|
+
try {
|
|
54
|
+
return statSync(dir).isDirectory() ? dir : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {(cmd: string) => string|null} which
|
|
62
|
+
* @param {{ start?: string }} [opts] folder the dialog opens at, ignored unless it exists
|
|
63
|
+
* @returns {Promise<string|null>} the chosen folder, or null when the user cancels
|
|
64
|
+
* @throws when this machine has no folder dialog
|
|
65
|
+
*/
|
|
66
|
+
function pickFolder(which, opts = {}, platform = process.platform, env = process.env) {
|
|
67
|
+
if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
|
|
68
|
+
return Promise.reject(new Error('no display for a folder dialog'));
|
|
69
|
+
}
|
|
70
|
+
const start = startFolder(opts.start);
|
|
71
|
+
const found = candidates(platform, start).find(([cmd]) => which(cmd));
|
|
72
|
+
if (!found) return Promise.reject(new Error('no folder dialog on this machine (install zenity or kdialog)'));
|
|
73
|
+
const [cmd, args] = found;
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawn(which(cmd), args, {
|
|
76
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
env: start ? { ...env, PK_PICK_START: start } : env,
|
|
79
|
+
});
|
|
80
|
+
let out = '';
|
|
81
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
82
|
+
child.on('error', reject);
|
|
83
|
+
// Every picker exits non-zero on cancel and prints nothing.
|
|
84
|
+
child.on('close', () => {
|
|
85
|
+
const dir = out.trim();
|
|
86
|
+
resolve(dir ? dir.replace(/(.)[/\\]$/, '$1') : null);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { pickFolder, candidates, startFolder };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Whether a page lets the board frame it, from its response headers. The parent page gets no
|
|
2
|
+
// signal when a cross-origin frame is refused (Chrome fires `load` on its error page), so the
|
|
3
|
+
// server reads the headers instead. Rules: CSP3 frame-ancestors (it wins over X-Frame-Options
|
|
4
|
+
// when present) and the HTML spec's X-Frame-Options processing.
|
|
5
|
+
|
|
6
|
+
const { portOf } = require('./panes');
|
|
7
|
+
|
|
8
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
function parseOrigin(value) {
|
|
11
|
+
try {
|
|
12
|
+
const u = new URL(value);
|
|
13
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// CSP3 lets an http source match https too.
|
|
20
|
+
const schemeOk = (want, got) => want === got || (want === 'http:' && got === 'https:');
|
|
21
|
+
|
|
22
|
+
function matchesSource(source, ancestor, self) {
|
|
23
|
+
const s = source.toLowerCase();
|
|
24
|
+
if (s === "'self'") return ancestor.origin === self.origin;
|
|
25
|
+
if (s === '*') return true;
|
|
26
|
+
if (/^[a-z][a-z0-9+.-]*:$/.test(s)) return schemeOk(s, ancestor.protocol);
|
|
27
|
+
const m = /^(?:([a-z][a-z0-9+.-]*):\/\/)?(\*|(?:\*\.)?[^:/]+)(?::(\d+|\*))?(?:\/.*)?$/.exec(s);
|
|
28
|
+
if (!m) return false;
|
|
29
|
+
const [, scheme, host, port] = m;
|
|
30
|
+
const want = scheme ? `${scheme}:` : self.protocol;
|
|
31
|
+
if (!schemeOk(want, ancestor.protocol)) return false;
|
|
32
|
+
const hostOk = host === '*' || (host.startsWith('*.') ? ancestor.hostname.endsWith(host.slice(1)) : host === ancestor.hostname);
|
|
33
|
+
if (!hostOk) return false;
|
|
34
|
+
if (port === '*' || (!port && ancestor.port === '')) return true;
|
|
35
|
+
return Number(port) === portOf(ancestor) || (port === '80' && ancestor.protocol === 'https:' && ancestor.port === '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function frameAncestorsLists(csp) {
|
|
39
|
+
const lists = [];
|
|
40
|
+
for (const policy of String(csp || '').split(',')) {
|
|
41
|
+
for (const directive of policy.split(';')) {
|
|
42
|
+
const [name, ...sources] = directive.trim().split(/\s+/);
|
|
43
|
+
if (name?.toLowerCase() === 'frame-ancestors') {
|
|
44
|
+
lists.push(sources);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return lists;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// headers: Fetch Headers. target: the final URL of the response. ancestors: every origin above
|
|
53
|
+
// the frame. Answers true, false, or null when it cannot tell.
|
|
54
|
+
function framingVerdict(headers, { target, ancestors }) {
|
|
55
|
+
const self = parseOrigin(target);
|
|
56
|
+
const chain = ancestors.map(parseOrigin).filter(Boolean);
|
|
57
|
+
if (!self || !chain.length) return null;
|
|
58
|
+
const lists = frameAncestorsLists(headers.get('content-security-policy'));
|
|
59
|
+
if (lists.length) return lists.every((sources) => chain.every((a) => sources.some((src) => matchesSource(src, a, self))));
|
|
60
|
+
const xfo = String(headers.get('x-frame-options') || '')
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((v) => v.trim().toLowerCase());
|
|
63
|
+
if (xfo.includes('deny')) return false;
|
|
64
|
+
return !xfo.includes('sameorigin') || chain.every((a) => a.origin === self.origin);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// GETs the page and drops the body once the headers arrive. A network error or a timeout
|
|
68
|
+
// answers null, and the board frames the page anyway.
|
|
69
|
+
async function probeFraming(target, ancestors, { fetchImpl = fetch } = {}) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetchImpl(target, { redirect: 'follow', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
72
|
+
res.body?.cancel().catch(() => {});
|
|
73
|
+
return framingVerdict(res.headers, { target: res.url || target, ancestors });
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { framingVerdict, parseOrigin, probeFraming };
|
package/lib/git-branch.js
CHANGED
|
@@ -1,112 +1,47 @@
|
|
|
1
|
+
// Read from HEAD rather than by spawning git: a sync spawn on the session-list path blocks the
|
|
2
|
+
// event loop for up to half a second per cwd on Windows, and terminal input waits behind it.
|
|
3
|
+
|
|
1
4
|
const fs = require('node:fs');
|
|
2
5
|
const path = require('node:path');
|
|
3
|
-
const { spawn } = require('node:child_process');
|
|
4
|
-
|
|
5
|
-
// cwd -> { branch: string|null, headMtimeMs: number, isRepo: boolean, checkedAt: number }
|
|
6
|
-
const cache = new Map();
|
|
7
|
-
const inflight = new Map();
|
|
8
|
-
|
|
9
|
-
const MAX_CONCURRENT = 4;
|
|
10
|
-
const SPAWN_TIMEOUT_MS = 500;
|
|
11
|
-
// re-check non-repos every 30s so newly-cloned repos surface without a server restart
|
|
12
|
-
const NEGATIVE_TTL_MS = 30_000;
|
|
13
|
-
// skip statSync within this window of a known-good cache hit; keeps list endpoints from
|
|
14
|
-
// hitting the FS once per session per request when the same cwds repeat
|
|
15
|
-
const POSITIVE_TTL_MS = 2_000;
|
|
16
|
-
let running = 0;
|
|
17
|
-
const queue = [];
|
|
18
|
-
|
|
19
|
-
function pump() {
|
|
20
|
-
while (running < MAX_CONCURRENT && queue.length) {
|
|
21
|
-
const job = queue.shift();
|
|
22
|
-
running++;
|
|
23
|
-
job().finally(() => { running--; pump(); });
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function headMtime(cwd) {
|
|
28
|
-
try { return fs.statSync(path.join(cwd, '.git', 'HEAD')).mtimeMs; }
|
|
29
|
-
catch { return null; }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function spawnGit(cwd) {
|
|
33
|
-
return new Promise((resolve) => {
|
|
34
|
-
let stdout = '';
|
|
35
|
-
let done = false;
|
|
36
|
-
let proc;
|
|
37
|
-
try {
|
|
38
|
-
proc = spawn('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
39
|
-
windowsHide: true,
|
|
40
|
-
});
|
|
41
|
-
} catch { resolve(null); return; }
|
|
42
|
-
const timer = setTimeout(() => {
|
|
43
|
-
if (done) return;
|
|
44
|
-
done = true;
|
|
45
|
-
try { proc.kill(); } catch {}
|
|
46
|
-
resolve(null);
|
|
47
|
-
}, SPAWN_TIMEOUT_MS);
|
|
48
|
-
proc.stdout.on('data', (b) => { stdout += b.toString(); });
|
|
49
|
-
proc.on('error', () => {
|
|
50
|
-
if (done) return;
|
|
51
|
-
done = true; clearTimeout(timer); resolve(null);
|
|
52
|
-
});
|
|
53
|
-
proc.on('close', (code) => {
|
|
54
|
-
if (done) return;
|
|
55
|
-
done = true; clearTimeout(timer);
|
|
56
|
-
if (code !== 0) { resolve(null); return; }
|
|
57
|
-
const branch = stdout.trim();
|
|
58
|
-
resolve(branch || null);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
6
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (entry && entry.isRepo === false && now - entry.checkedAt < NEGATIVE_TTL_MS) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
cache.set(cwd, { branch: null, headMtimeMs: 0, isRepo: false, checkedAt: now });
|
|
7
|
+
const REF_RE = /^ref:\s*refs\/heads\/(.+)$/;
|
|
8
|
+
const GITDIR_RE = /^gitdir:\s*(.+)$/;
|
|
9
|
+
|
|
10
|
+
// `.git` is a directory in an ordinary checkout and a `gitdir:` pointer file in a linked
|
|
11
|
+
// worktree or a submodule.
|
|
12
|
+
function headFile(dir, readFile) {
|
|
13
|
+
const dotGit = path.join(dir, '.git');
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = readFile(dotGit);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (e.code === 'EISDIR') return path.join(dotGit, 'HEAD');
|
|
79
19
|
return null;
|
|
80
20
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
entry.checkedAt = now;
|
|
84
|
-
return entry.branch;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
scheduleResolve(cwd, mtime, entry, onResolved);
|
|
88
|
-
return entry && entry.isRepo ? entry.branch : null;
|
|
21
|
+
const m = GITDIR_RE.exec(text.trim());
|
|
22
|
+
return m ? path.join(path.resolve(dir, m[1]), 'HEAD') : null;
|
|
89
23
|
}
|
|
90
24
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} cwd
|
|
27
|
+
* @param {(file: string) => string} [readFile]
|
|
28
|
+
* @returns {string|null} null outside a repo and on a detached HEAD
|
|
29
|
+
*/
|
|
30
|
+
function readGitBranch(cwd, readFile = (f) => fs.readFileSync(f, 'utf8')) {
|
|
31
|
+
for (let dir = path.resolve(cwd); ; ) {
|
|
32
|
+
const head = headFile(dir, readFile);
|
|
33
|
+
if (head) {
|
|
34
|
+
try {
|
|
35
|
+
const m = REF_RE.exec(readFile(head).trim());
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const up = path.dirname(dir);
|
|
42
|
+
if (up === dir) return null;
|
|
43
|
+
dir = up;
|
|
97
44
|
}
|
|
98
|
-
const prevBranch = prevEntry ? prevEntry.branch : undefined;
|
|
99
|
-
const p = new Promise((resolve) => {
|
|
100
|
-
queue.push(async () => {
|
|
101
|
-
const branch = await spawnGit(cwd);
|
|
102
|
-
cache.set(cwd, { branch, headMtimeMs: mtime, isRepo: branch !== null, checkedAt: Date.now() });
|
|
103
|
-
inflight.delete(cwd);
|
|
104
|
-
resolve(branch);
|
|
105
|
-
});
|
|
106
|
-
pump();
|
|
107
|
-
});
|
|
108
|
-
inflight.set(cwd, p);
|
|
109
|
-
if (onResolved) p.then((b) => { if (b !== prevBranch) onResolved(b); });
|
|
110
45
|
}
|
|
111
46
|
|
|
112
|
-
module.exports = {
|
|
47
|
+
module.exports = { readGitBranch };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// An error a route answers with `status`; `code` is the machine-readable reason, where one is set.
|
|
2
|
+
// `expose` lets the API error handler send the message for a 5xx too, because it was written for the user.
|
|
3
|
+
function httpError(status, message, code) {
|
|
4
|
+
return Object.assign(new Error(message), { status, code, expose: true });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
module.exports = { httpError };
|