@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,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
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import type { ZodType } from "zod";
|
|
5
|
+
|
|
6
|
+
export interface PluginStorageOptions<T> {
|
|
7
|
+
defaultData?: T;
|
|
8
|
+
/**
|
|
9
|
+
* Base directory override. When provided, storage is located at path.join(baseDir, pluginId).
|
|
10
|
+
* When omitted, defaults to path.join(os.homedir(), ".paseo", namespace ?? "plugin-data/xpufx", pluginId).
|
|
11
|
+
*/
|
|
12
|
+
baseDir?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Namespace directory under ~/.paseo. Defaults to "plugin-data/xpufx".
|
|
15
|
+
*/
|
|
16
|
+
namespace?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Optional Zod schema to validate and parse data on read/write, automatically applying defaults.
|
|
19
|
+
*/
|
|
20
|
+
schema?: ZodType<T>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StorageStats {
|
|
24
|
+
path: string;
|
|
25
|
+
fileCount: number;
|
|
26
|
+
totalBytes: number;
|
|
27
|
+
lastModified: Date | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_NAMESPACE_README = `# Paseo Plugins Storage (xpufx)
|
|
31
|
+
|
|
32
|
+
This directory is managed by \`paseo-plugin-helper\` to store persistent settings, cached metrics, and state for plugins.
|
|
33
|
+
- Safe to inspect or backup.
|
|
34
|
+
- Avoid editing files manually while the Paseo daemon is active.
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scoped, atomic filesystem-backed document storage for Paseo daemon plugins.
|
|
39
|
+
* Automatically handles directory creation, atomic temporary file swaps,
|
|
40
|
+
* schema validation, default state fallback, and isolated namespace auditing.
|
|
41
|
+
* State lives under ~/.paseo/plugin-data/xpufx/<pluginId>/ by default.
|
|
42
|
+
*/
|
|
43
|
+
export class PluginStorage<T extends Record<string, any>> {
|
|
44
|
+
readonly pluginId: string;
|
|
45
|
+
readonly filename: string;
|
|
46
|
+
readonly pluginDir: string;
|
|
47
|
+
readonly filePath: string;
|
|
48
|
+
readonly namespaceDir: string | null;
|
|
49
|
+
readonly defaultData?: T;
|
|
50
|
+
readonly schema?: ZodType<T>;
|
|
51
|
+
|
|
52
|
+
constructor(pluginId: string, filename = "state.json", options: PluginStorageOptions<T> = {}) {
|
|
53
|
+
this.pluginId = pluginId;
|
|
54
|
+
this.filename = filename;
|
|
55
|
+
this.defaultData = options.defaultData;
|
|
56
|
+
this.schema = options.schema;
|
|
57
|
+
|
|
58
|
+
const namespace = options.namespace ?? "plugin-data/xpufx";
|
|
59
|
+
if (options.baseDir) {
|
|
60
|
+
this.namespaceDir = options.baseDir;
|
|
61
|
+
this.pluginDir = path.join(options.baseDir, pluginId);
|
|
62
|
+
} else {
|
|
63
|
+
this.namespaceDir = path.join(os.homedir(), ".paseo", namespace);
|
|
64
|
+
this.pluginDir = path.join(this.namespaceDir, pluginId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.filePath = path.join(this.pluginDir, filename);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private ensureDir(): void {
|
|
71
|
+
if (this.namespaceDir && !fs.existsSync(this.namespaceDir)) {
|
|
72
|
+
fs.mkdirSync(this.namespaceDir, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
if (this.namespaceDir) {
|
|
75
|
+
const readmePath = path.join(this.namespaceDir, "README.md");
|
|
76
|
+
if (!fs.existsSync(readmePath)) {
|
|
77
|
+
try {
|
|
78
|
+
fs.writeFileSync(readmePath, DEFAULT_NAMESPACE_README, "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
// Non-fatal if filesystem is read-only
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const dir = path.dirname(this.filePath);
|
|
85
|
+
if (!fs.existsSync(dir)) {
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private getDefault(): T {
|
|
91
|
+
if (this.schema) {
|
|
92
|
+
const result = this.schema.safeParse(this.defaultData ?? {});
|
|
93
|
+
if (result.success) {
|
|
94
|
+
return result.data;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return this.defaultData ? (JSON.parse(JSON.stringify(this.defaultData)) as T) : ({} as T);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private parseData(raw: unknown): T {
|
|
101
|
+
if (this.schema) {
|
|
102
|
+
const result = this.schema.safeParse(raw);
|
|
103
|
+
if (result.success) {
|
|
104
|
+
return result.data;
|
|
105
|
+
}
|
|
106
|
+
return this.getDefault();
|
|
107
|
+
}
|
|
108
|
+
return raw as T;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Checks if the backing state file exists.
|
|
113
|
+
*/
|
|
114
|
+
exists(): boolean {
|
|
115
|
+
return fs.existsSync(this.filePath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reads data synchronously. If file does not exist, returns defaultData.
|
|
120
|
+
*/
|
|
121
|
+
read(): T {
|
|
122
|
+
try {
|
|
123
|
+
if (!fs.existsSync(this.filePath)) {
|
|
124
|
+
return this.getDefault();
|
|
125
|
+
}
|
|
126
|
+
const raw = fs.readFileSync(this.filePath, "utf8");
|
|
127
|
+
return this.parseData(JSON.parse(raw));
|
|
128
|
+
} catch {
|
|
129
|
+
return this.getDefault();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reads data asynchronously.
|
|
135
|
+
*/
|
|
136
|
+
async readAsync(): Promise<T> {
|
|
137
|
+
try {
|
|
138
|
+
if (!fs.existsSync(this.filePath)) {
|
|
139
|
+
return this.getDefault();
|
|
140
|
+
}
|
|
141
|
+
const raw = await fs.promises.readFile(this.filePath, "utf8");
|
|
142
|
+
return this.parseData(JSON.parse(raw));
|
|
143
|
+
} catch {
|
|
144
|
+
return this.getDefault();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Writes data atomically using a temporary file and atomic rename.
|
|
150
|
+
*/
|
|
151
|
+
write(data: T): void {
|
|
152
|
+
const validated = this.parseData(data);
|
|
153
|
+
this.ensureDir();
|
|
154
|
+
const tempPath = `${this.filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
155
|
+
const serialized = JSON.stringify(validated, null, 2);
|
|
156
|
+
fs.writeFileSync(tempPath, serialized, "utf8");
|
|
157
|
+
fs.renameSync(tempPath, this.filePath);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Writes data atomically using async filesystem operations.
|
|
162
|
+
*/
|
|
163
|
+
async writeAsync(data: T): Promise<void> {
|
|
164
|
+
const validated = this.parseData(data);
|
|
165
|
+
this.ensureDir();
|
|
166
|
+
const tempPath = `${this.filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
167
|
+
const serialized = JSON.stringify(validated, null, 2);
|
|
168
|
+
await fs.promises.writeFile(tempPath, serialized, "utf8");
|
|
169
|
+
await fs.promises.rename(tempPath, this.filePath);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Updates state synchronously using an updater function.
|
|
174
|
+
*/
|
|
175
|
+
update(updater: (prev: T) => T): T {
|
|
176
|
+
const current = this.read();
|
|
177
|
+
const updated = updater(current);
|
|
178
|
+
const validated = this.parseData(updated);
|
|
179
|
+
this.write(validated);
|
|
180
|
+
return validated;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Updates state asynchronously using an updater function.
|
|
185
|
+
*/
|
|
186
|
+
async updateAsync(updater: (prev: T) => Promise<T> | T): Promise<T> {
|
|
187
|
+
const current = await this.readAsync();
|
|
188
|
+
const updated = await updater(current);
|
|
189
|
+
const validated = this.parseData(updated);
|
|
190
|
+
await this.writeAsync(validated);
|
|
191
|
+
return validated;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Removes the state file if it exists.
|
|
196
|
+
*/
|
|
197
|
+
reset(): void {
|
|
198
|
+
if (fs.existsSync(this.filePath)) {
|
|
199
|
+
try {
|
|
200
|
+
fs.unlinkSync(this.filePath);
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Audits storage consumption, returning path, fileCount, totalBytes, and lastModified.
|
|
207
|
+
*/
|
|
208
|
+
async getStorageStats(): Promise<StorageStats> {
|
|
209
|
+
const stats: StorageStats = {
|
|
210
|
+
path: this.pluginDir,
|
|
211
|
+
fileCount: 0,
|
|
212
|
+
totalBytes: 0,
|
|
213
|
+
lastModified: null,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (!fs.existsSync(this.pluginDir)) {
|
|
217
|
+
return stats;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const entries = await fs.promises.readdir(this.pluginDir, { withFileTypes: true });
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (entry.isFile()) {
|
|
224
|
+
stats.fileCount++;
|
|
225
|
+
const target = path.join(this.pluginDir, entry.name);
|
|
226
|
+
const st = await fs.promises.stat(target);
|
|
227
|
+
stats.totalBytes += st.size;
|
|
228
|
+
if (!stats.lastModified || st.mtime > stats.lastModified) {
|
|
229
|
+
stats.lastModified = st.mtime;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch {}
|
|
234
|
+
|
|
235
|
+
return stats;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Alias for getStorageStats()
|
|
240
|
+
*/
|
|
241
|
+
async getStats(): Promise<StorageStats> {
|
|
242
|
+
return this.getStorageStats();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
|
|
3
|
+
export interface CpuCoreMetrics {
|
|
4
|
+
model: string;
|
|
5
|
+
speed: number;
|
|
6
|
+
usagePercent: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SystemMetrics {
|
|
10
|
+
hostname: string;
|
|
11
|
+
platform: NodeJS.Platform;
|
|
12
|
+
arch: string;
|
|
13
|
+
uptimeSeconds: number;
|
|
14
|
+
cpu: {
|
|
15
|
+
model: string;
|
|
16
|
+
cores: number;
|
|
17
|
+
usagePercent: number;
|
|
18
|
+
loadAverage: [number, number, number];
|
|
19
|
+
};
|
|
20
|
+
memory: {
|
|
21
|
+
totalBytes: number;
|
|
22
|
+
freeBytes: number;
|
|
23
|
+
usedBytes: number;
|
|
24
|
+
usedPercent: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CpuTickSample {
|
|
29
|
+
idle: number;
|
|
30
|
+
total: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Samples CPU ticks and calculates delta usage percentage over time.
|
|
35
|
+
*/
|
|
36
|
+
export class CpuSampler {
|
|
37
|
+
private lastSample: CpuTickSample[] | null = null;
|
|
38
|
+
|
|
39
|
+
constructor() {
|
|
40
|
+
this.lastSample = this.getTicks();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private getTicks(): CpuTickSample[] {
|
|
44
|
+
const cpus = os.cpus();
|
|
45
|
+
return cpus.map((cpu) => {
|
|
46
|
+
const times = cpu.times;
|
|
47
|
+
const total =
|
|
48
|
+
times.user + times.nice + times.sys + times.idle + times.irq;
|
|
49
|
+
return { idle: times.idle, total };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Computes the CPU usage delta since the previous sample (or between two samples).
|
|
55
|
+
* Returns average usage percentage (0 - 100) and per-core breakdown.
|
|
56
|
+
*/
|
|
57
|
+
sample(): { usagePercent: number; perCore: number[] } {
|
|
58
|
+
const current = this.getTicks();
|
|
59
|
+
if (!this.lastSample || this.lastSample.length !== current.length) {
|
|
60
|
+
this.lastSample = current;
|
|
61
|
+
return { usagePercent: 0, perCore: current.map(() => 0) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const perCore: number[] = [];
|
|
65
|
+
let totalActive = 0;
|
|
66
|
+
let totalTime = 0;
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < current.length; i++) {
|
|
69
|
+
const prev = this.lastSample[i];
|
|
70
|
+
const cur = current[i];
|
|
71
|
+
|
|
72
|
+
const deltaTotal = cur.total - prev.total;
|
|
73
|
+
const deltaIdle = cur.idle - prev.idle;
|
|
74
|
+
const deltaActive = Math.max(0, deltaTotal - deltaIdle);
|
|
75
|
+
|
|
76
|
+
const corePercent =
|
|
77
|
+
deltaTotal > 0 ? (deltaActive / deltaTotal) * 100 : 0;
|
|
78
|
+
perCore.push(Math.round(corePercent * 10) / 10);
|
|
79
|
+
|
|
80
|
+
totalActive += deltaActive;
|
|
81
|
+
totalTime += deltaTotal;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.lastSample = current;
|
|
85
|
+
|
|
86
|
+
const usagePercent =
|
|
87
|
+
totalTime > 0 ? (totalActive / totalTime) * 100 : 0;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
usagePercent: Math.round(usagePercent * 10) / 10,
|
|
91
|
+
perCore,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const defaultSampler = new CpuSampler();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns instant system resources and metrics (CPU, RAM, load averages, uptime).
|
|
100
|
+
*/
|
|
101
|
+
export function getSystemMetrics(sampler: CpuSampler = defaultSampler): SystemMetrics {
|
|
102
|
+
const cpus = os.cpus();
|
|
103
|
+
const totalmem = os.totalmem();
|
|
104
|
+
const freemem = os.freemem();
|
|
105
|
+
const usedmem = totalmem - freemem;
|
|
106
|
+
const cpuModel = cpus[0]?.model || "Unknown CPU";
|
|
107
|
+
|
|
108
|
+
const { usagePercent } = sampler.sample();
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
hostname: os.hostname(),
|
|
112
|
+
platform: process.platform,
|
|
113
|
+
arch: process.arch,
|
|
114
|
+
uptimeSeconds: Math.floor(os.uptime()),
|
|
115
|
+
cpu: {
|
|
116
|
+
model: cpuModel,
|
|
117
|
+
cores: cpus.length,
|
|
118
|
+
usagePercent,
|
|
119
|
+
loadAverage: os.loadavg() as [number, number, number],
|
|
120
|
+
},
|
|
121
|
+
memory: {
|
|
122
|
+
totalBytes: totalmem,
|
|
123
|
+
freeBytes: freemem,
|
|
124
|
+
usedBytes: usedmem,
|
|
125
|
+
usedPercent: Math.round((usedmem / totalmem) * 1000) / 10,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|