refactor(scripts): share guard runners and paged select UI

This commit is contained in:
Peter Steinberger
2026-03-02 14:36:00 +00:00
parent e41f9998f7
commit dbc78243f4
10 changed files with 234 additions and 294 deletions

View File

@@ -6,15 +6,7 @@
*/ */
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent"; import { showPagedSelectList } from "./ui/paged-select";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
interface FileInfo { interface FileInfo {
status: string; status: string;
@@ -108,87 +100,17 @@ export default function (pi: ExtensionAPI) {
} }
}; };
// Show file picker with SelectList const items = files.map((file) => ({
await ctx.ui.custom<void>((tui, theme, _kb, done) => { value: file,
const container = new Container(); label: `${file.status} ${file.file}`,
}));
// Top border await showPagedSelectList({
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); ctx,
title: " Select file to diff",
// Title items,
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0)); onSelect: (item) => {
// Build select items with colored status
const items: SelectItem[] = files.map((f) => {
let statusColor: string;
switch (f.status) {
case "M":
statusColor = theme.fg("warning", f.status);
break;
case "A":
statusColor = theme.fg("success", f.status);
break;
case "D":
statusColor = theme.fg("error", f.status);
break;
case "?":
statusColor = theme.fg("muted", f.status);
break;
default:
statusColor = theme.fg("dim", f.status);
}
return {
value: f,
label: `${statusColor} ${f.file}`,
};
});
const visibleRows = Math.min(files.length, 15);
let currentIndex = 0;
const selectList = new SelectList(items, visibleRows, {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => t, // Keep existing colors
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => {
void openSelected(item.value as FileInfo); void openSelected(item.value as FileInfo);
}; },
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
}); });
}, },
}); });

View File

@@ -6,15 +6,7 @@
*/ */
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent"; import { showPagedSelectList } from "./ui/paged-select";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
interface FileEntry { interface FileEntry {
path: string; path: string;
@@ -113,82 +105,30 @@ export default function (pi: ExtensionAPI) {
} }
}; };
// Show file picker with SelectList const items = files.map((file) => {
await ctx.ui.custom<void>((tui, theme, _kb, done) => { const ops: string[] = [];
const container = new Container(); if (file.operations.has("read")) {
ops.push("R");
// Top border }
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); if (file.operations.has("write")) {
ops.push("W");
// Title }
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0)); if (file.operations.has("edit")) {
ops.push("E");
// Build select items with colored operations }
const items: SelectItem[] = files.map((f) => {
const ops: string[] = [];
if (f.operations.has("read")) {
ops.push(theme.fg("muted", "R"));
}
if (f.operations.has("write")) {
ops.push(theme.fg("success", "W"));
}
if (f.operations.has("edit")) {
ops.push(theme.fg("warning", "E"));
}
const opsLabel = ops.join("");
return {
value: f,
label: `${opsLabel} ${f.path}`,
};
});
const visibleRows = Math.min(files.length, 15);
let currentIndex = 0;
const selectList = new SelectList(items, visibleRows, {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => t, // Keep existing colors
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
});
selectList.onSelect = (item) => {
void openSelected(item.value as FileEntry);
};
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = items.indexOf(item);
};
container.addChild(selectList);
// Help text
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return { return {
render: (w) => container.render(w), value: file,
invalidate: () => container.invalidate(), label: `${ops.join("")} ${file.path}`,
handleInput: (data) => {
// Add paging with left/right
if (matchesKey(data, Key.left)) {
// Page up - clamp to 0
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
// Page down - clamp to last
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
}; };
}); });
await showPagedSelectList({
ctx,
title: " Select file to open",
items,
onSelect: (item) => {
void openSelected(item.value as FileEntry);
},
});
}, },
}); });
} }

View File

@@ -0,0 +1,82 @@
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
import {
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
} from "@mariozechner/pi-tui";
type CustomUiContext = {
ui: {
custom: <T>(
render: (
tui: { requestRender: () => void },
theme: {
fg: (tone: string, text: string) => string;
bold: (text: string) => string;
},
kb: unknown,
done: () => void,
) => {
render: (width: number) => string;
invalidate: () => void;
handleInput: (data: string) => void;
},
) => Promise<T>;
};
};
export async function showPagedSelectList(params: {
ctx: CustomUiContext;
title: string;
items: SelectItem[];
onSelect: (item: SelectItem) => void;
}): Promise<void> {
await params.ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
container.addChild(new Text(theme.fg("accent", theme.bold(params.title)), 0, 0));
const visibleRows = Math.min(params.items.length, 15);
let currentIndex = 0;
const selectList = new SelectList(params.items, visibleRows, {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => text,
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
});
selectList.onSelect = (item) => params.onSelect(item);
selectList.onCancel = () => done();
selectList.onSelectionChange = (item) => {
currentIndex = params.items.indexOf(item);
};
container.addChild(selectList);
container.addChild(
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
);
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (width) => container.render(width),
invalidate: () => container.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.left)) {
currentIndex = Math.max(0, currentIndex - visibleRows);
selectList.setSelectedIndex(currentIndex);
} else if (matchesKey(data, Key.right)) {
currentIndex = Math.min(params.items.length - 1, currentIndex + visibleRows);
selectList.setSelectedIndex(currentIndex);
} else {
selectList.handleInput(data);
}
tui.requestRender();
},
};
});
}

View File

@@ -1,24 +1,22 @@
#!/usr/bin/env node #!/usr/bin/env node
import path from "node:path";
import ts from "typescript"; import ts from "typescript";
import { createPairingGuardContext } from "./lib/pairing-guard-context.mjs";
import { import {
collectFileViolations, collectFileViolations,
getPropertyNameText, getPropertyNameText,
resolveRepoRoot,
runAsScript, runAsScript,
toLine, toLine,
} from "./lib/ts-guard-utils.mjs"; } from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url); const { repoRoot, sourceRoots, resolveFromRepo } = createPairingGuardContext(import.meta.url);
const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")];
const allowedFiles = new Set([ const allowedFiles = new Set([
path.join(repoRoot, "src", "security", "dm-policy-shared.ts"), resolveFromRepo("src/security/dm-policy-shared.ts"),
path.join(repoRoot, "src", "channels", "allow-from.ts"), resolveFromRepo("src/channels/allow-from.ts"),
// Config migration/audit logic may intentionally reference store + group fields. // Config migration/audit logic may intentionally reference store + group fields.
path.join(repoRoot, "src", "security", "fix.ts"), resolveFromRepo("src/security/fix.ts"),
path.join(repoRoot, "src", "security", "audit-channel.ts"), resolveFromRepo("src/security/audit-channel.ts"),
]); ]);
const storeIdentifierRe = /^(?:storeAllowFrom|storedAllowFrom|storeAllowList)$/i; const storeIdentifierRe = /^(?:storeAllowFrom|storedAllowFrom|storeAllowList)$/i;

View File

@@ -1,25 +1,17 @@
#!/usr/bin/env node #!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import ts from "typescript"; import ts from "typescript";
import { import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
collectTypeScriptFiles, import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
resolveRepoRoot,
runAsScript,
toLine,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = [ const sourceRoots = [
path.join(repoRoot, "src", "channels"), "src/channels",
path.join(repoRoot, "src", "infra", "outbound"), "src/infra/outbound",
path.join(repoRoot, "src", "line"), "src/line",
path.join(repoRoot, "src", "media-understanding"), "src/media-understanding",
path.join(repoRoot, "extensions"), "extensions",
]; ];
const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]); const allowedRelativePaths = new Set(["extensions/feishu/src/dedup.ts"]);
function collectOsTmpdirImports(sourceFile) { function collectOsTmpdirImports(sourceFile) {
const osModuleSpecifiers = new Set(["node:os", "os"]); const osModuleSpecifiers = new Set(["node:os", "os"]);
@@ -82,40 +74,16 @@ export function findMessagingTmpdirCallLines(content, fileName = "source.ts") {
} }
export async function main() { export async function main() {
const files = ( await runCallsiteGuard({
await Promise.all( importMetaUrl: import.meta.url,
sourceRoots.map( sourceRoots,
async (dir) => findCallLines: findMessagingTmpdirCallLines,
await collectTypeScriptFiles(dir, { skipRelativePath: (relativePath) => allowedRelativePaths.has(relativePath),
ignoreMissing: true, header: "Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:",
}), footer:
), "Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.",
) sortViolations: false,
).flat(); });
const violations = [];
for (const filePath of files) {
if (allowedCallsites.has(filePath)) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
for (const line of findMessagingTmpdirCallLines(content, filePath)) {
violations.push(`${path.relative(repoRoot, filePath)}:${line}`);
}
}
if (violations.length === 0) {
return;
}
console.error("Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error(
"Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.",
);
process.exit(1);
} }
runAsScript(import.meta.url, main); runAsScript(import.meta.url, main);

View File

@@ -1,28 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import ts from "typescript"; import ts from "typescript";
import { import { runCallsiteGuard } from "./lib/callsite-guard.mjs";
collectTypeScriptFiles, import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs";
resolveRepoRoot,
runAsScript,
toLine,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = [ const sourceRoots = [
path.join(repoRoot, "src", "telegram"), "src/telegram",
path.join(repoRoot, "src", "discord"), "src/discord",
path.join(repoRoot, "src", "slack"), "src/slack",
path.join(repoRoot, "src", "signal"), "src/signal",
path.join(repoRoot, "src", "imessage"), "src/imessage",
path.join(repoRoot, "src", "web"), "src/web",
path.join(repoRoot, "src", "channels"), "src/channels",
path.join(repoRoot, "src", "routing"), "src/routing",
path.join(repoRoot, "src", "line"), "src/line",
path.join(repoRoot, "extensions"), "extensions",
]; ];
// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime // Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime
@@ -100,43 +92,15 @@ export function findRawFetchCallLines(content, fileName = "source.ts") {
} }
export async function main() { export async function main() {
const files = ( await runCallsiteGuard({
await Promise.all( importMetaUrl: import.meta.url,
sourceRoots.map( sourceRoots,
async (sourceRoot) => extraTestSuffixes: [".browser.test.ts", ".node.test.ts"],
await collectTypeScriptFiles(sourceRoot, { findCallLines: findRawFetchCallLines,
extraTestSuffixes: [".browser.test.ts", ".node.test.ts"], allowCallsite: (callsite) => allowedRawFetchCallsites.has(callsite),
ignoreMissing: true, header: "Found raw fetch() usage in channel/plugin runtime sources outside allowlist:",
}), footer: "Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.",
), });
)
).flat();
const violations = [];
for (const filePath of files) {
const content = await fs.readFile(filePath, "utf8");
const relPath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/");
for (const line of findRawFetchCallLines(content, filePath)) {
const callsite = `${relPath}:${line}`;
if (allowedRawFetchCallsites.has(callsite)) {
continue;
}
violations.push(callsite);
}
}
if (violations.length === 0) {
return;
}
console.error("Found raw fetch() usage in channel/plugin runtime sources outside allowlist:");
for (const violation of violations.toSorted()) {
console.error(`- ${violation}`);
}
console.error(
"Use fetchWithSsrFGuard() or existing channel/plugin SDK wrappers for network calls.",
);
process.exit(1);
} }
runAsScript(import.meta.url, main); runAsScript(import.meta.url, main);

View File

@@ -1,17 +1,15 @@
#!/usr/bin/env node #!/usr/bin/env node
import path from "node:path";
import ts from "typescript"; import ts from "typescript";
import { createPairingGuardContext } from "./lib/pairing-guard-context.mjs";
import { import {
collectFileViolations, collectFileViolations,
getPropertyNameText, getPropertyNameText,
resolveRepoRoot,
runAsScript, runAsScript,
toLine, toLine,
} from "./lib/ts-guard-utils.mjs"; } from "./lib/ts-guard-utils.mjs";
const repoRoot = resolveRepoRoot(import.meta.url); const { repoRoot, sourceRoots } = createPairingGuardContext(import.meta.url);
const sourceRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")];
function isUndefinedLikeExpression(node) { function isUndefinedLikeExpression(node) {
if (ts.isIdentifier(node) && node.text === "undefined") { if (ts.isIdentifier(node) && node.text === "undefined") {

View File

@@ -0,0 +1,45 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import {
collectTypeScriptFilesFromRoots,
resolveRepoRoot,
resolveSourceRoots,
} from "./ts-guard-utils.mjs";
export async function runCallsiteGuard(params) {
const repoRoot = resolveRepoRoot(params.importMetaUrl);
const sourceRoots = resolveSourceRoots(repoRoot, params.sourceRoots);
const files = await collectTypeScriptFilesFromRoots(sourceRoots, {
extraTestSuffixes: params.extraTestSuffixes,
});
const violations = [];
for (const filePath of files) {
const relPath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/");
if (params.skipRelativePath?.(relPath)) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
for (const line of params.findCallLines(content, filePath)) {
const callsite = `${relPath}:${line}`;
if (params.allowCallsite?.(callsite)) {
continue;
}
violations.push(callsite);
}
}
if (violations.length === 0) {
return;
}
console.error(params.header);
const output = params.sortViolations === false ? violations : violations.toSorted();
for (const violation of output) {
console.error(`- ${violation}`);
}
if (params.footer) {
console.error(params.footer);
}
process.exit(1);
}

View File

@@ -0,0 +1,13 @@
import path from "node:path";
import { resolveRepoRoot, resolveSourceRoots } from "./ts-guard-utils.mjs";
export function createPairingGuardContext(importMetaUrl) {
const repoRoot = resolveRepoRoot(importMetaUrl);
const sourceRoots = resolveSourceRoots(repoRoot, ["src", "extensions"]);
return {
repoRoot,
sourceRoots,
resolveFromRepo: (relativePath) =>
path.join(repoRoot, ...relativePath.split("/").filter(Boolean)),
};
}

View File

@@ -9,6 +9,10 @@ export function resolveRepoRoot(importMetaUrl) {
return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", ".."); return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", "..");
} }
export function resolveSourceRoots(repoRoot, relativeRoots) {
return relativeRoots.map((root) => path.join(repoRoot, ...root.split("/").filter(Boolean)));
}
export function isTestLikeTypeScriptFile(filePath, options = {}) { export function isTestLikeTypeScriptFile(filePath, options = {}) {
const extraTestSuffixes = options.extraTestSuffixes ?? []; const extraTestSuffixes = options.extraTestSuffixes ?? [];
return [...baseTestSuffixes, ...extraTestSuffixes].some((suffix) => filePath.endsWith(suffix)); return [...baseTestSuffixes, ...extraTestSuffixes].some((suffix) => filePath.endsWith(suffix));
@@ -68,18 +72,24 @@ export async function collectTypeScriptFiles(targetPath, options = {}) {
return out; return out;
} }
export async function collectFileViolations(params) { export async function collectTypeScriptFilesFromRoots(sourceRoots, options = {}) {
const files = ( return (
await Promise.all( await Promise.all(
params.sourceRoots.map( sourceRoots.map(
async (root) => async (root) =>
await collectTypeScriptFiles(root, { await collectTypeScriptFiles(root, {
ignoreMissing: true, ignoreMissing: true,
extraTestSuffixes: params.extraTestSuffixes, ...options,
}), }),
), ),
) )
).flat(); ).flat();
}
export async function collectFileViolations(params) {
const files = await collectTypeScriptFilesFromRoots(params.sourceRoots, {
extraTestSuffixes: params.extraTestSuffixes,
});
const violations = []; const violations = [];
for (const filePath of files) { for (const filePath of files) {