@xpufx/paseo-twofado 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 +62 -0
- package/client/approvals.tsx +1525 -0
- package/client/error-boundary.tsx +28 -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/package.json +36 -0
- package/paseo-plugin.json +6 -0
- package/server/twofado.ts +536 -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/approval.ts +254 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ZodType } from "zod";
|
|
4
|
+
import { defineSettingsContract, type SettingsContract } from "../../../shared/vendor/paseo-plugin-helper/settings";
|
|
5
|
+
import { PluginStorage } from "./storage";
|
|
6
|
+
import {
|
|
7
|
+
createSettingsHandlers,
|
|
8
|
+
registerSettingsRpc,
|
|
9
|
+
type HandleableServerContext,
|
|
10
|
+
type RegisterSettingsRpcOptions,
|
|
11
|
+
} from "./settings";
|
|
12
|
+
|
|
13
|
+
export interface SharedPluginSettingsOptions<TSettings extends Record<string, any>> {
|
|
14
|
+
suite: string;
|
|
15
|
+
filename?: string;
|
|
16
|
+
schema: ZodType<TSettings> & { partial?: () => ZodType<Partial<TSettings>> };
|
|
17
|
+
defaultData?: Partial<TSettings>;
|
|
18
|
+
contract?: SettingsContract<TSettings>;
|
|
19
|
+
contractName?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
namespace?: string;
|
|
22
|
+
baseDir?: string;
|
|
23
|
+
watchDebounceMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SharedSettingsListener<TSettings> = (settings: TSettings) => void;
|
|
27
|
+
|
|
28
|
+
export interface SharedPluginSettings<TSettings extends Record<string, any>> {
|
|
29
|
+
readonly suite: string;
|
|
30
|
+
readonly contract: SettingsContract<TSettings>;
|
|
31
|
+
readonly storage: PluginStorage<TSettings>;
|
|
32
|
+
readonly filePath: string;
|
|
33
|
+
get(): Promise<TSettings>;
|
|
34
|
+
update(patch: Partial<TSettings>): Promise<TSettings>;
|
|
35
|
+
reset(): Promise<TSettings>;
|
|
36
|
+
read(): TSettings;
|
|
37
|
+
reload(): TSettings;
|
|
38
|
+
subscribe(listener: SharedSettingsListener<TSettings>): () => void;
|
|
39
|
+
watch(listener: SharedSettingsListener<TSettings>): () => void;
|
|
40
|
+
register(
|
|
41
|
+
context: HandleableServerContext,
|
|
42
|
+
options?: RegisterSettingsRpcOptions<TSettings>,
|
|
43
|
+
): void;
|
|
44
|
+
createHandlers(options?: RegisterSettingsRpcOptions<TSettings>): {
|
|
45
|
+
get: () => Promise<TSettings>;
|
|
46
|
+
update: (input: unknown) => Promise<TSettings>;
|
|
47
|
+
reset: () => Promise<TSettings>;
|
|
48
|
+
};
|
|
49
|
+
dispose(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeSerialize(value: unknown): string {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.stringify(value) ?? "";
|
|
55
|
+
} catch {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Creates a suite-scoped settings store shared across independent sibling plugins.
|
|
62
|
+
* Every plugin in the suite points at the same file
|
|
63
|
+
* (`~/.paseo/plugin-data/xpufx/<suite>/<filename>`) through an atomic PluginStorage,
|
|
64
|
+
* so an update written by Plugin A is immediately readable by Plugin B.
|
|
65
|
+
*/
|
|
66
|
+
export function createSharedPluginSettings<TSettings extends Record<string, any>>(
|
|
67
|
+
options: SharedPluginSettingsOptions<TSettings>,
|
|
68
|
+
): SharedPluginSettings<TSettings> {
|
|
69
|
+
const {
|
|
70
|
+
suite,
|
|
71
|
+
filename = "settings.json",
|
|
72
|
+
schema,
|
|
73
|
+
defaultData,
|
|
74
|
+
description,
|
|
75
|
+
namespace,
|
|
76
|
+
baseDir,
|
|
77
|
+
watchDebounceMs = 25,
|
|
78
|
+
} = options;
|
|
79
|
+
const contractName = options.contractName ?? `${suite}.shared-settings`;
|
|
80
|
+
|
|
81
|
+
const contract =
|
|
82
|
+
options.contract ??
|
|
83
|
+
defineSettingsContract({
|
|
84
|
+
name: contractName,
|
|
85
|
+
schema,
|
|
86
|
+
...(defaultData !== undefined ? { defaultData } : {}),
|
|
87
|
+
...(description !== undefined ? { description } : {}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const storage = new PluginStorage<TSettings>(suite, filename, {
|
|
91
|
+
...(namespace !== undefined ? { namespace } : {}),
|
|
92
|
+
...(baseDir !== undefined ? { baseDir } : {}),
|
|
93
|
+
schema: contract.schema,
|
|
94
|
+
defaultData: contract.defaultSettings,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const listeners = new Set<SharedSettingsListener<TSettings>>();
|
|
98
|
+
let watcher: fs.FSWatcher | null = null;
|
|
99
|
+
let debounceTimer: NodeJS.Timeout | null = null;
|
|
100
|
+
let lastSnapshot = "";
|
|
101
|
+
|
|
102
|
+
function snapshot(): string {
|
|
103
|
+
return safeSerialize(storage.read());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function emitIfChanged(): void {
|
|
107
|
+
const next = storage.read();
|
|
108
|
+
const nextSnapshot = safeSerialize(next);
|
|
109
|
+
if (nextSnapshot !== lastSnapshot) {
|
|
110
|
+
lastSnapshot = nextSnapshot;
|
|
111
|
+
for (const listener of [...listeners]) {
|
|
112
|
+
try {
|
|
113
|
+
listener(next);
|
|
114
|
+
} catch {
|
|
115
|
+
// Listener errors must not break sibling notification fan-out
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function scheduleEmit(): void {
|
|
122
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
123
|
+
// @ts-ignore
|
|
124
|
+
debounceTimer = setTimeout(() => {
|
|
125
|
+
debounceTimer = null;
|
|
126
|
+
try {
|
|
127
|
+
emitIfChanged();
|
|
128
|
+
} catch {
|
|
129
|
+
// Disk reads may race atomic renames; next event retries
|
|
130
|
+
}
|
|
131
|
+
}, watchDebounceMs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ensureWatcher(): void {
|
|
135
|
+
if (watcher) return;
|
|
136
|
+
const dir = path.dirname(storage.filePath);
|
|
137
|
+
if (!fs.existsSync(dir)) {
|
|
138
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
+
}
|
|
140
|
+
lastSnapshot = snapshot();
|
|
141
|
+
watcher = fs.watch(dir, (_event, watchedFile) => {
|
|
142
|
+
if (watchedFile && watchedFile.toString() !== path.basename(storage.filePath)) return;
|
|
143
|
+
scheduleEmit();
|
|
144
|
+
});
|
|
145
|
+
watcher.on("error", () => {});
|
|
146
|
+
if (typeof (watcher as unknown as { unref?: () => void }).unref === "function") {
|
|
147
|
+
(watcher as unknown as { unref: () => void }).unref();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function disposeWatcher(): void {
|
|
152
|
+
if (debounceTimer) {
|
|
153
|
+
// @ts-ignore
|
|
154
|
+
clearTimeout(debounceTimer);
|
|
155
|
+
debounceTimer = null;
|
|
156
|
+
}
|
|
157
|
+
if (watcher) {
|
|
158
|
+
try {
|
|
159
|
+
watcher.close();
|
|
160
|
+
} catch {}
|
|
161
|
+
watcher = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function subscribe(listener: SharedSettingsListener<TSettings>): () => void {
|
|
166
|
+
listeners.add(listener);
|
|
167
|
+
try {
|
|
168
|
+
ensureWatcher();
|
|
169
|
+
} catch {
|
|
170
|
+
// Watching is best-effort; polling via reload() still works
|
|
171
|
+
}
|
|
172
|
+
return () => {
|
|
173
|
+
listeners.delete(listener);
|
|
174
|
+
if (listeners.size === 0) disposeWatcher();
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
suite,
|
|
180
|
+
contract,
|
|
181
|
+
storage,
|
|
182
|
+
filePath: storage.filePath,
|
|
183
|
+
|
|
184
|
+
async get(): Promise<TSettings> {
|
|
185
|
+
return storage.readAsync();
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async update(patch: Partial<TSettings>): Promise<TSettings> {
|
|
189
|
+
const updated = await storage.updateAsync((current) => ({
|
|
190
|
+
...current,
|
|
191
|
+
...patch,
|
|
192
|
+
}));
|
|
193
|
+
lastSnapshot = safeSerialize(updated);
|
|
194
|
+
for (const listener of [...listeners]) {
|
|
195
|
+
try {
|
|
196
|
+
listener(updated);
|
|
197
|
+
} catch {}
|
|
198
|
+
}
|
|
199
|
+
return updated;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
async reset(): Promise<TSettings> {
|
|
203
|
+
storage.reset();
|
|
204
|
+
const fresh = await storage.readAsync();
|
|
205
|
+
lastSnapshot = safeSerialize(fresh);
|
|
206
|
+
for (const listener of [...listeners]) {
|
|
207
|
+
try {
|
|
208
|
+
listener(fresh);
|
|
209
|
+
} catch {}
|
|
210
|
+
}
|
|
211
|
+
return fresh;
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
read(): TSettings {
|
|
215
|
+
return storage.read();
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
reload(): TSettings {
|
|
219
|
+
const next = storage.read();
|
|
220
|
+
lastSnapshot = safeSerialize(next);
|
|
221
|
+
return next;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
subscribe,
|
|
225
|
+
watch: subscribe,
|
|
226
|
+
|
|
227
|
+
register(
|
|
228
|
+
context: HandleableServerContext,
|
|
229
|
+
rpcOptions: RegisterSettingsRpcOptions<TSettings> = {},
|
|
230
|
+
): void {
|
|
231
|
+
registerSettingsRpc(context, contract, storage, rpcOptions);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
createHandlers(rpcOptions: RegisterSettingsRpcOptions<TSettings> = {}) {
|
|
235
|
+
return createSettingsHandlers(contract, storage, rpcOptions);
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
dispose(): void {
|
|
239
|
+
listeners.clear();
|
|
240
|
+
disposeWatcher();
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|