@xpufx/paseo-forges 0.1.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/LICENSE +21 -0
- package/README.md +109 -0
- package/client/active-forge.ts +47 -0
- package/client/board-alert.tsx +236 -0
- package/client/foreign-link.tsx +33 -0
- package/client/hook-queue-panel.tsx +412 -0
- package/client/issues-pill.tsx +1976 -0
- package/client/label-chip.tsx +116 -0
- package/client/linkifier.tsx +280 -0
- package/client/pill-label.ts +183 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/client/webhook-card.tsx +198 -0
- package/docs/specs/forge-workflow-gui.md +820 -0
- package/docs/workflow.md +339 -0
- package/examples/README.md +108 -0
- package/examples/hook-service/README.md +66 -0
- package/examples/hook-service/forge-hook.service +28 -0
- package/examples/hook-service/hook-server.mjs +265 -0
- package/examples/hook-service/hook.env.example +21 -0
- package/examples/labels/README.md +64 -0
- package/examples/labels/label-base.yaml +117 -0
- package/examples/skills/coding-agent/SKILL.md +262 -0
- package/examples/skills/coding-agent-fgjx/SKILL.md +271 -0
- package/examples/skills/orchestrator/SKILL.md +133 -0
- package/examples/skills/orchestrator-fgjx/SKILL.md +139 -0
- package/examples/tools/README.md +68 -0
- package/examples/tools/fgjx +464 -0
- package/package.json +38 -0
- package/paseo-plugin.json +4 -0
- package/server/forge-client.ts +484 -0
- package/server/forge-guard.ts +70 -0
- package/server/git-origin.ts +70 -0
- package/server/hook-queue.ts +127 -0
- package/server/issues.ts +542 -0
- package/server/settings.ts +52 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/hook-queue.ts +140 -0
- package/shared/issues.ts +1750 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- package/shared/version.ts +2 -0
- package/shared/webhook.ts +223 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
export interface PingHostOptions {
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks whether a TCP port is currently open and accepting connections on a host.
|
|
9
|
+
*/
|
|
10
|
+
export function isPortOpen(port: number, host = "127.0.0.1", options?: PingHostOptions): Promise<boolean> {
|
|
11
|
+
const timeoutMs = options?.timeoutMs ?? 1000;
|
|
12
|
+
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const socket = new net.Socket();
|
|
15
|
+
let settled = false;
|
|
16
|
+
|
|
17
|
+
const cleanup = () => {
|
|
18
|
+
if (!settled) {
|
|
19
|
+
settled = true;
|
|
20
|
+
socket.destroy();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
socket.setTimeout(timeoutMs);
|
|
25
|
+
|
|
26
|
+
socket.once("connect", () => {
|
|
27
|
+
cleanup();
|
|
28
|
+
resolve(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
socket.once("timeout", () => {
|
|
32
|
+
cleanup();
|
|
33
|
+
resolve(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
socket.once("error", () => {
|
|
37
|
+
cleanup();
|
|
38
|
+
resolve(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
socket.connect(port, host);
|
|
43
|
+
} catch {
|
|
44
|
+
cleanup();
|
|
45
|
+
resolve(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Finds an available TCP port starting from startPort.
|
|
52
|
+
* Tries up to maxAttempts ports.
|
|
53
|
+
*/
|
|
54
|
+
export function findAvailablePort(startPort = 3000, maxAttempts = 50, host = "127.0.0.1"): Promise<number> {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
let currentPort = startPort;
|
|
57
|
+
const endPort = startPort + maxAttempts;
|
|
58
|
+
|
|
59
|
+
function tryNext() {
|
|
60
|
+
if (currentPort >= endPort) {
|
|
61
|
+
return reject(new Error(`No available port found in range [${startPort}, ${endPort})`));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const server = net.createServer();
|
|
65
|
+
|
|
66
|
+
server.once("error", () => {
|
|
67
|
+
currentPort++;
|
|
68
|
+
tryNext();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
server.once("listening", () => {
|
|
72
|
+
const addr = server.address();
|
|
73
|
+
const port = typeof addr === "object" && addr ? addr.port : currentPort;
|
|
74
|
+
server.close(() => {
|
|
75
|
+
resolve(port);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
server.listen(currentPort, host);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
tryNext();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pings a host and port via TCP to verify connectivity.
|
|
88
|
+
*/
|
|
89
|
+
export async function pingHost(host: string, port: number, options?: PingHostOptions): Promise<boolean> {
|
|
90
|
+
return isPortOpen(port, host, options);
|
|
91
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { safeSpawn } from "./process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
export interface PaseoPluginInfo {
|
|
7
|
+
id: string;
|
|
8
|
+
path: string;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
status: "running" | "disabled" | "failed" | string;
|
|
11
|
+
source?: "directory" | "git" | string;
|
|
12
|
+
remote?: string;
|
|
13
|
+
ref?: string;
|
|
14
|
+
commit?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type PluginStatusFilter = "all" | "enabled" | "disabled" | "running" | "failed";
|
|
19
|
+
|
|
20
|
+
export interface ListPluginsOptions {
|
|
21
|
+
/** Filter results by status or enablement. Defaults to "all". */
|
|
22
|
+
filter?: PluginStatusFilter;
|
|
23
|
+
/** In-memory TTL cache duration in milliseconds. Defaults to 5000ms. */
|
|
24
|
+
cacheTtlMs?: number;
|
|
25
|
+
/** If true, bypasses the in-memory cache and queries the daemon fresh. */
|
|
26
|
+
forceRefresh?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let cachedPlugins: PaseoPluginInfo[] | null = null;
|
|
30
|
+
let lastFetchTime = 0;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Clears the in-memory plugin list cache.
|
|
34
|
+
*/
|
|
35
|
+
export function clearPluginCache(): void {
|
|
36
|
+
cachedPlugins = null;
|
|
37
|
+
lastFetchTime = 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reads configured plugins fallback from ~/.paseo/config.json when CLI is unavailable.
|
|
42
|
+
*/
|
|
43
|
+
function readConfigPluginsFallback(): PaseoPluginInfo[] {
|
|
44
|
+
try {
|
|
45
|
+
const configPath = path.join(os.homedir(), ".paseo", "config.json");
|
|
46
|
+
if (!fs.existsSync(configPath)) return [];
|
|
47
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
const pluginsObj = parsed?.plugins;
|
|
50
|
+
if (!pluginsObj || typeof pluginsObj !== "object") return [];
|
|
51
|
+
|
|
52
|
+
return Object.entries(pluginsObj).map(([id, val]: [string, any]) => {
|
|
53
|
+
const isEnabled = val?.enabled !== false;
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
path: val?.path ?? "",
|
|
57
|
+
enabled: isEnabled,
|
|
58
|
+
status: isEnabled ? "unknown" : "disabled",
|
|
59
|
+
source: val?.source,
|
|
60
|
+
remote: val?.remote,
|
|
61
|
+
ref: val?.ref,
|
|
62
|
+
commit: val?.commit,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lists plugins from the Paseo daemon, with optional filtering and TTL caching.
|
|
72
|
+
* Primary mechanism uses 'paseo plugin ls --json', with fallback to ~/.paseo/config.json.
|
|
73
|
+
*/
|
|
74
|
+
export async function listPlugins(options: ListPluginsOptions = {}): Promise<PaseoPluginInfo[]> {
|
|
75
|
+
const { filter = "all", cacheTtlMs = 5000, forceRefresh = false } = options;
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
|
|
78
|
+
if (!forceRefresh && cachedPlugins && now - lastFetchTime < cacheTtlMs) {
|
|
79
|
+
return applyFilter(cachedPlugins, filter);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let plugins: PaseoPluginInfo[] = [];
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const result = await safeSpawn("paseo", ["plugin", "ls", "--json"], { timeoutMs: 3000 });
|
|
86
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
87
|
+
plugins = JSON.parse(result.stdout.trim());
|
|
88
|
+
} else {
|
|
89
|
+
plugins = readConfigPluginsFallback();
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
plugins = readConfigPluginsFallback();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
cachedPlugins = plugins;
|
|
96
|
+
lastFetchTime = now;
|
|
97
|
+
|
|
98
|
+
return applyFilter(plugins, filter);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function applyFilter(plugins: PaseoPluginInfo[], filter: PluginStatusFilter): PaseoPluginInfo[] {
|
|
102
|
+
switch (filter) {
|
|
103
|
+
case "enabled":
|
|
104
|
+
return plugins.filter((p) => p.enabled);
|
|
105
|
+
case "disabled":
|
|
106
|
+
return plugins.filter((p) => !p.enabled);
|
|
107
|
+
case "running":
|
|
108
|
+
return plugins.filter((p) => p.status === "running");
|
|
109
|
+
case "failed":
|
|
110
|
+
return plugins.filter((p) => p.status === "failed");
|
|
111
|
+
case "all":
|
|
112
|
+
default:
|
|
113
|
+
return plugins;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Retrieves metadata for a specific plugin by ID.
|
|
119
|
+
* Returns null if the plugin is not installed or found.
|
|
120
|
+
*/
|
|
121
|
+
export async function getPluginInfo(
|
|
122
|
+
pluginId: string,
|
|
123
|
+
options?: { cacheTtlMs?: number; forceRefresh?: boolean },
|
|
124
|
+
): Promise<PaseoPluginInfo | null> {
|
|
125
|
+
const plugins = await listPlugins(options);
|
|
126
|
+
return plugins.find((p) => p.id === pluginId) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Checks whether a plugin is installed in Paseo.
|
|
131
|
+
*/
|
|
132
|
+
export async function isPluginInstalled(
|
|
133
|
+
pluginId: string,
|
|
134
|
+
options?: { cacheTtlMs?: number; forceRefresh?: boolean },
|
|
135
|
+
): Promise<boolean> {
|
|
136
|
+
const info = await getPluginInfo(pluginId, options);
|
|
137
|
+
return info !== null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Checks whether a plugin is installed and marked as enabled in Paseo.
|
|
142
|
+
*/
|
|
143
|
+
export async function isPluginEnabled(
|
|
144
|
+
pluginId: string,
|
|
145
|
+
options?: { cacheTtlMs?: number; forceRefresh?: boolean },
|
|
146
|
+
): Promise<boolean> {
|
|
147
|
+
const info = await getPluginInfo(pluginId, options);
|
|
148
|
+
return info !== null && info.enabled;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Checks whether a plugin is currently running in the Paseo daemon.
|
|
153
|
+
*/
|
|
154
|
+
export async function isPluginRunning(
|
|
155
|
+
pluginId: string,
|
|
156
|
+
options?: { cacheTtlMs?: number; forceRefresh?: boolean },
|
|
157
|
+
): Promise<boolean> {
|
|
158
|
+
const info = await getPluginInfo(pluginId, options);
|
|
159
|
+
return info !== null && info.status === "running";
|
|
160
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawn, type SpawnOptions } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface SafeSpawnOptions extends SpawnOptions {
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
maxBuffer?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Kills a spawned child together with everything it started. Plain
|
|
10
|
+
* child.kill() only signals the direct child, so grandchildren (df, git
|
|
11
|
+
* helpers, shell pipelines) orphan and accumulate. Negative-pid kill targets
|
|
12
|
+
* the whole process group, which requires the child to lead its own group
|
|
13
|
+
* (detached spawn, POSIX only).
|
|
14
|
+
*/
|
|
15
|
+
function killProcessGroup(
|
|
16
|
+
child: ReturnType<typeof spawn>,
|
|
17
|
+
signal: NodeJS.Signals,
|
|
18
|
+
): void {
|
|
19
|
+
try {
|
|
20
|
+
if (
|
|
21
|
+
process.platform !== "win32" &&
|
|
22
|
+
child.pid !== undefined
|
|
23
|
+
) {
|
|
24
|
+
process.kill(-child.pid, signal);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// Group kill missed (already gone or no permission); fall through.
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
child.kill(signal);
|
|
32
|
+
} catch {
|
|
33
|
+
// Already gone.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SafeSpawnResult {
|
|
38
|
+
stdout: string;
|
|
39
|
+
stderr: string;
|
|
40
|
+
code: number | null;
|
|
41
|
+
signal: NodeJS.Signals | null;
|
|
42
|
+
durationMs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Spawns a process safely with timeout management, stdout/stderr capture,
|
|
47
|
+
* and clean process group termination without shell vulnerabilities.
|
|
48
|
+
*/
|
|
49
|
+
export function safeSpawn(
|
|
50
|
+
command: string,
|
|
51
|
+
args: string[] = [],
|
|
52
|
+
options: SafeSpawnOptions = {},
|
|
53
|
+
): Promise<SafeSpawnResult> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const startTime = Date.now();
|
|
56
|
+
const timeoutMs = options.timeoutMs ?? 15000;
|
|
57
|
+
const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024; // 10MB
|
|
58
|
+
|
|
59
|
+
const child = spawn(command, args, {
|
|
60
|
+
...options,
|
|
61
|
+
shell: false,
|
|
62
|
+
detached: options.detached ?? process.platform !== "win32",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let stdout = "";
|
|
66
|
+
let stderr = "";
|
|
67
|
+
let exited = false;
|
|
68
|
+
let timedOut = false;
|
|
69
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
|
|
71
|
+
if (timeoutMs > 0) {
|
|
72
|
+
timer = setTimeout(() => {
|
|
73
|
+
timedOut = true;
|
|
74
|
+
killProcessGroup(child, "SIGTERM");
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
if (!exited) killProcessGroup(child, "SIGKILL");
|
|
77
|
+
}, 2000);
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
82
|
+
if (stdout.length < maxBuffer) {
|
|
83
|
+
stdout += chunk.toString();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
88
|
+
if (stderr.length < maxBuffer) {
|
|
89
|
+
stderr += chunk.toString();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
child.on("error", (err) => {
|
|
94
|
+
if (timer) clearTimeout(timer);
|
|
95
|
+
reject(err);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
child.on("close", (code, signal) => {
|
|
99
|
+
exited = true;
|
|
100
|
+
if (timer) clearTimeout(timer);
|
|
101
|
+
const durationMs = Date.now() - startTime;
|
|
102
|
+
if (timedOut) {
|
|
103
|
+
reject(new Error(`Command '${command}' timed out after ${timeoutMs}ms`));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
resolve({
|
|
107
|
+
stdout: stdout.trim(),
|
|
108
|
+
stderr: stderr.trim(),
|
|
109
|
+
code,
|
|
110
|
+
signal,
|
|
111
|
+
durationMs,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Executes a shell command line string with timeout protection, stdout/stderr capture,
|
|
119
|
+
* and clean process termination.
|
|
120
|
+
*/
|
|
121
|
+
export function safeExec(
|
|
122
|
+
command: string,
|
|
123
|
+
options: SafeSpawnOptions = {},
|
|
124
|
+
): Promise<SafeSpawnResult> {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const startTime = Date.now();
|
|
127
|
+
const timeoutMs = options.timeoutMs ?? 15000;
|
|
128
|
+
const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024; // 10MB
|
|
129
|
+
|
|
130
|
+
const child = spawn(command, {
|
|
131
|
+
...options,
|
|
132
|
+
shell: true,
|
|
133
|
+
detached: options.detached ?? process.platform !== "win32",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
let stdout = "";
|
|
137
|
+
let stderr = "";
|
|
138
|
+
let exited = false;
|
|
139
|
+
let timedOut = false;
|
|
140
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
141
|
+
|
|
142
|
+
if (timeoutMs > 0) {
|
|
143
|
+
timer = setTimeout(() => {
|
|
144
|
+
timedOut = true;
|
|
145
|
+
killProcessGroup(child, "SIGTERM");
|
|
146
|
+
setTimeout(() => {
|
|
147
|
+
if (!exited) killProcessGroup(child, "SIGKILL");
|
|
148
|
+
}, 2000);
|
|
149
|
+
}, timeoutMs);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
153
|
+
if (stdout.length < maxBuffer) {
|
|
154
|
+
stdout += chunk.toString();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
159
|
+
if (stderr.length < maxBuffer) {
|
|
160
|
+
stderr += chunk.toString();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
child.on("error", (err) => {
|
|
165
|
+
if (timer) clearTimeout(timer);
|
|
166
|
+
reject(err);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
child.on("close", (code, signal) => {
|
|
170
|
+
exited = true;
|
|
171
|
+
if (timer) clearTimeout(timer);
|
|
172
|
+
const durationMs = Date.now() - startTime;
|
|
173
|
+
if (timedOut) {
|
|
174
|
+
reject(new Error(`Command '${command}' timed out after ${timeoutMs}ms`));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
resolve({
|
|
178
|
+
stdout: stdout.trim(),
|
|
179
|
+
stderr: stderr.trim(),
|
|
180
|
+
code,
|
|
181
|
+
signal,
|
|
182
|
+
durationMs,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface RedactOptions {
|
|
2
|
+
mask?: string;
|
|
3
|
+
customSensitiveKeys?: string[];
|
|
4
|
+
preserveLength?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const DEFAULT_SENSITIVE_KEYS = [
|
|
8
|
+
"password",
|
|
9
|
+
"passwd",
|
|
10
|
+
"secret",
|
|
11
|
+
"token",
|
|
12
|
+
"apikey",
|
|
13
|
+
"api_key",
|
|
14
|
+
"access_token",
|
|
15
|
+
"refresh_token",
|
|
16
|
+
"privatekey",
|
|
17
|
+
"private_key",
|
|
18
|
+
"authorization",
|
|
19
|
+
"auth",
|
|
20
|
+
"credential",
|
|
21
|
+
"credentials",
|
|
22
|
+
"cert",
|
|
23
|
+
"certificate",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function isSensitiveKey(key: string, customKeys: string[] = []): boolean {
|
|
27
|
+
const normalized = key.toLowerCase().replace(/[-_]/g, "");
|
|
28
|
+
return [...DEFAULT_SENSITIVE_KEYS, ...customKeys].some((k) =>
|
|
29
|
+
normalized.includes(k.replace(/[-_]/g, "")),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function maskString(val: string, mask = "[REDACTED]"): string {
|
|
34
|
+
if (val.length <= 8) return mask;
|
|
35
|
+
// Keep first 3 and last 3 characters if long enough, using custom mask if provided
|
|
36
|
+
const placeholder = mask === "[REDACTED]" ? "..." : mask;
|
|
37
|
+
return `${val.slice(0, 3)}${placeholder}${val.slice(-3)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Deeply redacts sensitive keys and values in an object or primitive before logging or sending over RPC.
|
|
42
|
+
*/
|
|
43
|
+
export function redactSecrets<T>(target: T, options: RedactOptions = {}): T {
|
|
44
|
+
const mask = options.mask ?? "[REDACTED]";
|
|
45
|
+
const customKeys = options.customSensitiveKeys ?? [];
|
|
46
|
+
|
|
47
|
+
if (target === null || target === undefined) return target;
|
|
48
|
+
|
|
49
|
+
if (typeof target === "string") {
|
|
50
|
+
// Redact Bearer tokens in headers
|
|
51
|
+
let result = target.replace(/(Bearer\s+)[A-Za-z0-9\-._~+/]+=*/gi, `$1${mask}`);
|
|
52
|
+
// Redact basic auth in URLs
|
|
53
|
+
result = result.replace(/(https?:\/\/[^:]+:)[^@]+(@)/gi, `$1${mask}$2`);
|
|
54
|
+
// Redact Paseo pairing offers (password-equivalent): app.paseo.sh/#offer=
|
|
55
|
+
// plus the base64 tail, optionally JSON-sniffed by serverId key below.
|
|
56
|
+
// Tail covers standard (+/=) and URL-safe (-_) base64 so partial
|
|
57
|
+
// offers never leak through a truncated match.
|
|
58
|
+
result = result.replace(
|
|
59
|
+
/(https?:\/\/app\.paseo\.sh\/#offer=)[A-Za-z0-9\-_.~+/]+=*/gi,
|
|
60
|
+
`$1${mask}`,
|
|
61
|
+
);
|
|
62
|
+
return result as unknown as T;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (Array.isArray(target)) {
|
|
66
|
+
return target.map((item) => redactSecrets(item, options)) as unknown as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof target === "object") {
|
|
70
|
+
const clone: Record<string, any> = {};
|
|
71
|
+
for (const [key, value] of Object.entries(target)) {
|
|
72
|
+
if (isSensitiveKey(key, customKeys)) {
|
|
73
|
+
clone[key] = typeof value === "string" ? maskString(value, mask) : mask;
|
|
74
|
+
} else if (typeof value === "object" && value !== null) {
|
|
75
|
+
clone[key] = redactSecrets(value, options);
|
|
76
|
+
} else if (typeof value === "string") {
|
|
77
|
+
clone[key] = redactSecrets(value, options);
|
|
78
|
+
} else {
|
|
79
|
+
clone[key] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return clone as T;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return target;
|
|
86
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { withTimeout, TimeoutError } from "../../../shared/vendor/paseo-plugin-helper/async";
|
|
2
|
+
|
|
3
|
+
export interface RpcGuardOptions<T> {
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
maxInflight?: number;
|
|
6
|
+
getStale?: () => T | null | undefined;
|
|
7
|
+
onTimeout?: (info: { timeoutMs: number; inflight: number }) => void;
|
|
8
|
+
onSaturated?: (info: { maxInflight: number }) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GuardedRpcHandler<TInput, TOutput> {
|
|
12
|
+
(input?: TInput): Promise<TOutput>;
|
|
13
|
+
inflight(): number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function guardRpcHandler<TInput, TOutput>(
|
|
17
|
+
handler: (input?: TInput) => Promise<TOutput> | TOutput,
|
|
18
|
+
options: RpcGuardOptions<TOutput> = {},
|
|
19
|
+
): GuardedRpcHandler<TInput, TOutput> {
|
|
20
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
21
|
+
const maxInflight = options.maxInflight ?? 4;
|
|
22
|
+
let inflight = 0;
|
|
23
|
+
|
|
24
|
+
const guarded = (async (input?: TInput): Promise<TOutput> => {
|
|
25
|
+
if (inflight >= maxInflight) {
|
|
26
|
+
options.onSaturated?.({ maxInflight });
|
|
27
|
+
const stale = options.getStale?.();
|
|
28
|
+
if (stale != null) {
|
|
29
|
+
return stale;
|
|
30
|
+
}
|
|
31
|
+
throw new Error(
|
|
32
|
+
`RPC handler saturated (${inflight} inflight, cap ${maxInflight}): shedding load`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
inflight += 1;
|
|
36
|
+
try {
|
|
37
|
+
return await withTimeout(
|
|
38
|
+
Promise.resolve().then(() => handler(input)),
|
|
39
|
+
timeoutMs,
|
|
40
|
+
"RPC handler",
|
|
41
|
+
);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err instanceof TimeoutError) {
|
|
44
|
+
options.onTimeout?.({ timeoutMs, inflight });
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
} finally {
|
|
48
|
+
inflight -= 1;
|
|
49
|
+
}
|
|
50
|
+
}) as GuardedRpcHandler<TInput, TOutput>;
|
|
51
|
+
guarded.inflight = () => inflight;
|
|
52
|
+
return guarded;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LoopWatchdogOptions {
|
|
56
|
+
thresholdMs?: number;
|
|
57
|
+
intervalMs?: number;
|
|
58
|
+
onLag?: (lagMs: number) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createLoopWatchdog(options: LoopWatchdogOptions = {}): () => void {
|
|
62
|
+
const thresholdMs = options.thresholdMs ?? 1000;
|
|
63
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
64
|
+
let last = Date.now();
|
|
65
|
+
const timer = setInterval(() => {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const lagMs = now - last - intervalMs;
|
|
68
|
+
last = now;
|
|
69
|
+
if (lagMs >= thresholdMs) {
|
|
70
|
+
options.onLag?.(lagMs);
|
|
71
|
+
}
|
|
72
|
+
}, intervalMs);
|
|
73
|
+
if (typeof (timer as unknown as { unref?: unknown }).unref === "function") {
|
|
74
|
+
(timer as unknown as { unref: () => void }).unref();
|
|
75
|
+
}
|
|
76
|
+
return () => clearInterval(timer);
|
|
77
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { SettingsContract } from "../../../shared/vendor/paseo-plugin-helper/settings";
|
|
2
|
+
import type { PluginStorage } from "./storage";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structural server context interface satisfied by both Paseo v0.7 PluginContext
|
|
6
|
+
* and Paseo v0.8 PluginServerContext.
|
|
7
|
+
*/
|
|
8
|
+
export interface HandleableServerContext {
|
|
9
|
+
handle(contract: any, handler: (input?: any) => any): any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RegisterSettingsRpcOptions<TSettings> {
|
|
13
|
+
/**
|
|
14
|
+
* Optional callback invoked whenever settings are updated.
|
|
15
|
+
*/
|
|
16
|
+
onUpdate?: (newSettings: TSettings, prevSettings: TSettings) => void | Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Optional callback invoked whenever settings are reset.
|
|
19
|
+
*/
|
|
20
|
+
onReset?: (defaultSettings: TSettings, prevSettings: TSettings) => void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registers RPC handlers on the daemon PluginContext/PluginServerContext for a SettingsContract.
|
|
25
|
+
* Connects get, update, and reset RPCs directly to atomic PluginStorage persistence.
|
|
26
|
+
* Works with both Paseo v0.7 and Paseo v0.8.
|
|
27
|
+
*/
|
|
28
|
+
export function registerSettingsRpc<TSettings extends Record<string, any>>(
|
|
29
|
+
context: HandleableServerContext,
|
|
30
|
+
contract: SettingsContract<TSettings>,
|
|
31
|
+
storage: PluginStorage<TSettings>,
|
|
32
|
+
options: RegisterSettingsRpcOptions<TSettings> = {},
|
|
33
|
+
): void {
|
|
34
|
+
// Register GET handler
|
|
35
|
+
context.handle(contract.get, async () => {
|
|
36
|
+
return storage.readAsync();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Register UPDATE handler
|
|
40
|
+
context.handle(contract.update, async (input) => {
|
|
41
|
+
const prev = await storage.readAsync();
|
|
42
|
+
const updated = await storage.updateAsync((current) => {
|
|
43
|
+
return { ...current, ...(input as Partial<TSettings>) };
|
|
44
|
+
});
|
|
45
|
+
if (options.onUpdate) {
|
|
46
|
+
await options.onUpdate(updated, prev);
|
|
47
|
+
}
|
|
48
|
+
return updated;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Register RESET handler
|
|
52
|
+
context.handle(contract.reset, async () => {
|
|
53
|
+
const prev = await storage.readAsync();
|
|
54
|
+
storage.reset();
|
|
55
|
+
const fresh = await storage.readAsync();
|
|
56
|
+
if (options.onReset) {
|
|
57
|
+
await options.onReset(fresh, prev);
|
|
58
|
+
}
|
|
59
|
+
return fresh;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Creates individual RPC handler functions (get, update, reset) for a SettingsContract.
|
|
65
|
+
* Useful when registering handlers in root index.ts via plugin.handle() so that
|
|
66
|
+
* Paseo's AST-based client compiler can cleanly strip all server registrations.
|
|
67
|
+
*/
|
|
68
|
+
export function createSettingsHandlers<TSettings extends Record<string, any>>(
|
|
69
|
+
contract: SettingsContract<TSettings>,
|
|
70
|
+
storage: PluginStorage<TSettings>,
|
|
71
|
+
options: RegisterSettingsRpcOptions<TSettings> = {},
|
|
72
|
+
) {
|
|
73
|
+
return {
|
|
74
|
+
get: async () => {
|
|
75
|
+
return storage.readAsync();
|
|
76
|
+
},
|
|
77
|
+
update: async (input: unknown) => {
|
|
78
|
+
const prev = await storage.readAsync();
|
|
79
|
+
const updated = await storage.updateAsync((current) => {
|
|
80
|
+
return { ...current, ...(input as Partial<TSettings>) };
|
|
81
|
+
});
|
|
82
|
+
if (options.onUpdate) {
|
|
83
|
+
await options.onUpdate(updated, prev);
|
|
84
|
+
}
|
|
85
|
+
return updated;
|
|
86
|
+
},
|
|
87
|
+
reset: async () => {
|
|
88
|
+
const prev = await storage.readAsync();
|
|
89
|
+
storage.reset();
|
|
90
|
+
const fresh = await storage.readAsync();
|
|
91
|
+
if (options.onReset) {
|
|
92
|
+
await options.onReset(fresh, prev);
|
|
93
|
+
}
|
|
94
|
+
return fresh;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|