@blackbelt-technology/pi-agent-dashboard 0.2.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/AGENTS.md +342 -0
- package/README.md +619 -0
- package/docs/architecture.md +646 -0
- package/package.json +92 -0
- package/packages/extension/package.json +33 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
- package/packages/extension/src/__tests__/connection.test.ts +344 -0
- package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
- package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
- package/packages/extension/src/__tests__/git-info.test.ts +112 -0
- package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
- package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
- package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
- package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
- package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
- package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
- package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
- package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
- package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
- package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
- package/packages/extension/src/ask-user-tool.ts +63 -0
- package/packages/extension/src/bridge-context.ts +64 -0
- package/packages/extension/src/bridge.ts +926 -0
- package/packages/extension/src/command-handler.ts +538 -0
- package/packages/extension/src/connection.ts +204 -0
- package/packages/extension/src/dev-build.ts +39 -0
- package/packages/extension/src/event-forwarder.ts +40 -0
- package/packages/extension/src/flow-event-wiring.ts +102 -0
- package/packages/extension/src/git-info.ts +65 -0
- package/packages/extension/src/git-link-builder.ts +112 -0
- package/packages/extension/src/model-tracker.ts +56 -0
- package/packages/extension/src/pi-env.d.ts +23 -0
- package/packages/extension/src/process-metrics.ts +70 -0
- package/packages/extension/src/process-scanner.ts +396 -0
- package/packages/extension/src/prompt-expander.ts +87 -0
- package/packages/extension/src/provider-register.ts +276 -0
- package/packages/extension/src/server-auto-start.ts +87 -0
- package/packages/extension/src/server-launcher.ts +82 -0
- package/packages/extension/src/server-probe.ts +33 -0
- package/packages/extension/src/session-sync.ts +154 -0
- package/packages/extension/src/source-detector.ts +26 -0
- package/packages/extension/src/ui-proxy.ts +269 -0
- package/packages/extension/tsconfig.json +11 -0
- package/packages/server/package.json +37 -0
- package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
- package/packages/server/src/__tests__/auth.test.ts +224 -0
- package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
- package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
- package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
- package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
- package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
- package/packages/server/src/__tests__/config-api.test.ts +104 -0
- package/packages/server/src/__tests__/cors.test.ts +48 -0
- package/packages/server/src/__tests__/directory-service.test.ts +240 -0
- package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
- package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
- package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
- package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
- package/packages/server/src/__tests__/extension-register.test.ts +61 -0
- package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
- package/packages/server/src/__tests__/git-operations.test.ts +251 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
- package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
- package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
- package/packages/server/src/__tests__/json-store.test.ts +70 -0
- package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
- package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
- package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
- package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
- package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
- package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
- package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
- package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
- package/packages/server/src/__tests__/package-routes.test.ts +172 -0
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
- package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
- package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
- package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
- package/packages/server/src/__tests__/process-manager.test.ts +184 -0
- package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
- package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
- package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
- package/packages/server/src/__tests__/server-pid.test.ts +89 -0
- package/packages/server/src/__tests__/session-api.test.ts +244 -0
- package/packages/server/src/__tests__/session-diff.test.ts +138 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
- package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
- package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
- package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
- package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
- package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
- package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
- package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
- package/packages/server/src/__tests__/tunnel.test.ts +206 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
- package/packages/server/src/auth-plugin.ts +302 -0
- package/packages/server/src/auth.ts +323 -0
- package/packages/server/src/browse.ts +55 -0
- package/packages/server/src/browser-gateway.ts +495 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
- package/packages/server/src/browser-handlers/handler-context.ts +45 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
- package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
- package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
- package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
- package/packages/server/src/cli.ts +347 -0
- package/packages/server/src/config-api.ts +130 -0
- package/packages/server/src/directory-service.ts +162 -0
- package/packages/server/src/editor-detection.ts +60 -0
- package/packages/server/src/editor-manager.ts +352 -0
- package/packages/server/src/editor-proxy.ts +134 -0
- package/packages/server/src/editor-registry.ts +108 -0
- package/packages/server/src/event-status-extraction.ts +131 -0
- package/packages/server/src/event-wiring.ts +589 -0
- package/packages/server/src/extension-register.ts +92 -0
- package/packages/server/src/git-operations.ts +200 -0
- package/packages/server/src/headless-pid-registry.ts +207 -0
- package/packages/server/src/idle-timer.ts +61 -0
- package/packages/server/src/json-store.ts +32 -0
- package/packages/server/src/localhost-guard.ts +117 -0
- package/packages/server/src/memory-event-store.ts +193 -0
- package/packages/server/src/memory-session-manager.ts +123 -0
- package/packages/server/src/meta-persistence.ts +64 -0
- package/packages/server/src/migrate-persistence.ts +195 -0
- package/packages/server/src/npm-search-proxy.ts +143 -0
- package/packages/server/src/oauth-callback-server.ts +177 -0
- package/packages/server/src/openspec-archive.ts +60 -0
- package/packages/server/src/package-manager-wrapper.ts +200 -0
- package/packages/server/src/pending-fork-registry.ts +53 -0
- package/packages/server/src/pending-load-manager.ts +110 -0
- package/packages/server/src/pending-resume-registry.ts +69 -0
- package/packages/server/src/pi-gateway.ts +419 -0
- package/packages/server/src/pi-resource-scanner.ts +369 -0
- package/packages/server/src/preferences-store.ts +116 -0
- package/packages/server/src/process-manager.ts +311 -0
- package/packages/server/src/provider-auth-handlers.ts +438 -0
- package/packages/server/src/provider-auth-storage.ts +200 -0
- package/packages/server/src/resolve-path.ts +12 -0
- package/packages/server/src/routes/editor-routes.ts +86 -0
- package/packages/server/src/routes/file-routes.ts +116 -0
- package/packages/server/src/routes/git-routes.ts +89 -0
- package/packages/server/src/routes/openspec-routes.ts +99 -0
- package/packages/server/src/routes/package-routes.ts +172 -0
- package/packages/server/src/routes/provider-auth-routes.ts +244 -0
- package/packages/server/src/routes/provider-routes.ts +101 -0
- package/packages/server/src/routes/route-deps.ts +23 -0
- package/packages/server/src/routes/session-routes.ts +91 -0
- package/packages/server/src/routes/system-routes.ts +271 -0
- package/packages/server/src/server-pid.ts +84 -0
- package/packages/server/src/server.ts +554 -0
- package/packages/server/src/session-api.ts +330 -0
- package/packages/server/src/session-bootstrap.ts +80 -0
- package/packages/server/src/session-diff.ts +178 -0
- package/packages/server/src/session-discovery.ts +134 -0
- package/packages/server/src/session-file-reader.ts +135 -0
- package/packages/server/src/session-order-manager.ts +73 -0
- package/packages/server/src/session-scanner.ts +233 -0
- package/packages/server/src/session-stats-reader.ts +99 -0
- package/packages/server/src/terminal-gateway.ts +51 -0
- package/packages/server/src/terminal-manager.ts +241 -0
- package/packages/server/src/tunnel.ts +329 -0
- package/packages/server/tsconfig.json +11 -0
- package/packages/shared/package.json +15 -0
- package/packages/shared/src/__tests__/config.test.ts +358 -0
- package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
- package/packages/shared/src/__tests__/protocol.test.ts +243 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
- package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
- package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
- package/packages/shared/src/archive-types.ts +11 -0
- package/packages/shared/src/browser-protocol.ts +534 -0
- package/packages/shared/src/config.ts +245 -0
- package/packages/shared/src/diff-types.ts +41 -0
- package/packages/shared/src/editor-types.ts +18 -0
- package/packages/shared/src/mdns-discovery.ts +248 -0
- package/packages/shared/src/openspec-activity-detector.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +96 -0
- package/packages/shared/src/protocol.ts +369 -0
- package/packages/shared/src/resolve-jiti.ts +43 -0
- package/packages/shared/src/rest-api.ts +255 -0
- package/packages/shared/src/server-identity.ts +51 -0
- package/packages/shared/src/session-meta.ts +86 -0
- package/packages/shared/src/state-replay.ts +174 -0
- package/packages/shared/src/stats-extractor.ts +54 -0
- package/packages/shared/src/terminal-types.ts +18 -0
- package/packages/shared/src/types.ts +351 -0
- package/packages/shared/tsconfig.json +8 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side git operations — branch listing, checkout, init, stash.
|
|
3
|
+
*/
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const GIT_TIMEOUT = 15_000;
|
|
7
|
+
|
|
8
|
+
/** Run a git command, return trimmed stdout. Throws on failure. */
|
|
9
|
+
function run(command: string, cwd: string): string {
|
|
10
|
+
return execSync(command, {
|
|
11
|
+
cwd,
|
|
12
|
+
encoding: "utf-8",
|
|
13
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
14
|
+
timeout: GIT_TIMEOUT,
|
|
15
|
+
}).trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Run a git command, return trimmed stdout or undefined on failure. */
|
|
19
|
+
function tryRun(command: string, cwd: string): string | undefined {
|
|
20
|
+
try {
|
|
21
|
+
return run(command, cwd);
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Check if cwd is inside a git work tree. */
|
|
28
|
+
export function isGitRepo(cwd: string): boolean {
|
|
29
|
+
return tryRun("git rev-parse --is-inside-work-tree", cwd) === "true";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Get list of dirty files from git status --porcelain. */
|
|
33
|
+
export function getDirtyFiles(cwd: string): string[] {
|
|
34
|
+
let output: string;
|
|
35
|
+
try {
|
|
36
|
+
output = execSync("git status --porcelain", {
|
|
37
|
+
cwd,
|
|
38
|
+
encoding: "utf-8",
|
|
39
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
40
|
+
timeout: GIT_TIMEOUT,
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return output
|
|
46
|
+
.split("\n")
|
|
47
|
+
.filter((line) => line.length > 0)
|
|
48
|
+
.map((line) => line.slice(3)); // strip 2 status chars + space
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BranchInfo {
|
|
52
|
+
current: string;
|
|
53
|
+
detached: boolean;
|
|
54
|
+
branches: Array<{ name: string; isRemote: boolean; isCurrent: boolean }>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** List all local and remote branches sorted by most recent commit. */
|
|
58
|
+
export function listBranches(cwd: string): BranchInfo {
|
|
59
|
+
// Detect current branch / detached HEAD
|
|
60
|
+
const headRef = tryRun("git rev-parse --abbrev-ref HEAD", cwd);
|
|
61
|
+
|
|
62
|
+
// Empty repo (no commits yet)
|
|
63
|
+
if (!headRef) {
|
|
64
|
+
// Try to read the default branch name from HEAD
|
|
65
|
+
const symbolic = tryRun("git symbolic-ref --short HEAD", cwd);
|
|
66
|
+
return {
|
|
67
|
+
current: symbolic ?? "main",
|
|
68
|
+
detached: false,
|
|
69
|
+
branches: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const detached = headRef === "HEAD";
|
|
73
|
+
const current = detached
|
|
74
|
+
? run("git rev-parse --short HEAD", cwd)
|
|
75
|
+
: headRef;
|
|
76
|
+
|
|
77
|
+
// List all branches with committer date sorting
|
|
78
|
+
const format = "%(refname:short)%(HEAD)";
|
|
79
|
+
const rawLocal = tryRun(
|
|
80
|
+
`git branch --sort=-committerdate --format="${format}"`,
|
|
81
|
+
cwd
|
|
82
|
+
) ?? "";
|
|
83
|
+
const rawRemote = tryRun(
|
|
84
|
+
`git branch -r --sort=-committerdate --format="${format}"`,
|
|
85
|
+
cwd
|
|
86
|
+
) ?? "";
|
|
87
|
+
|
|
88
|
+
const localBranches: BranchInfo["branches"] = [];
|
|
89
|
+
for (const line of rawLocal.split("\n").filter(Boolean)) {
|
|
90
|
+
const isCurrent = line.includes("*");
|
|
91
|
+
const name = line.replace("*", "").trim();
|
|
92
|
+
if (!name) continue;
|
|
93
|
+
localBranches.push({ name, isRemote: false, isCurrent });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Collect local branch names for dedup
|
|
97
|
+
const localNames = new Set(localBranches.map((b) => b.name));
|
|
98
|
+
|
|
99
|
+
const remoteBranches: BranchInfo["branches"] = [];
|
|
100
|
+
for (const line of rawRemote.split("\n").filter(Boolean)) {
|
|
101
|
+
const name = line.replace("*", "").trim();
|
|
102
|
+
// Skip HEAD pointers like "origin/HEAD"
|
|
103
|
+
if (name.endsWith("/HEAD")) continue;
|
|
104
|
+
// Skip remote branches that have a local counterpart
|
|
105
|
+
const localName = name.replace(/^[^/]+\//, ""); // "origin/foo" → "foo"
|
|
106
|
+
if (localNames.has(localName)) continue;
|
|
107
|
+
remoteBranches.push({ name, isRemote: true, isCurrent: false });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
current,
|
|
112
|
+
detached,
|
|
113
|
+
branches: [...localBranches, ...remoteBranches],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CheckoutResult {
|
|
118
|
+
success: true;
|
|
119
|
+
stashed?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface CheckoutDirty {
|
|
123
|
+
success: false;
|
|
124
|
+
dirty: true;
|
|
125
|
+
files: string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Checkout a branch. Returns dirty info if working tree is dirty and stash=false. */
|
|
129
|
+
export function checkoutBranch(
|
|
130
|
+
cwd: string,
|
|
131
|
+
branch: string,
|
|
132
|
+
stash: boolean
|
|
133
|
+
): CheckoutResult | CheckoutDirty {
|
|
134
|
+
// Already on this branch?
|
|
135
|
+
const headRef = tryRun("git rev-parse --abbrev-ref HEAD", cwd);
|
|
136
|
+
if (headRef === branch) return { success: true };
|
|
137
|
+
|
|
138
|
+
const dirtyFiles = getDirtyFiles(cwd);
|
|
139
|
+
|
|
140
|
+
if (dirtyFiles.length > 0 && !stash) {
|
|
141
|
+
return { success: false, dirty: true, files: dirtyFiles };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let stashed = false;
|
|
145
|
+
if (dirtyFiles.length > 0 && stash) {
|
|
146
|
+
run("git stash push -u -m \"pi-dashboard-auto-stash\"", cwd);
|
|
147
|
+
stashed = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if this is a remote branch that needs a local tracking branch
|
|
151
|
+
const isRemote = branch.includes("/");
|
|
152
|
+
if (isRemote) {
|
|
153
|
+
const localName = branch.replace(/^[^/]+\//, "");
|
|
154
|
+
// Check if local branch exists
|
|
155
|
+
const localExists = tryRun(`git rev-parse --verify refs/heads/${localName}`, cwd);
|
|
156
|
+
if (!localExists) {
|
|
157
|
+
run(`git checkout -b ${localName} ${branch}`, cwd);
|
|
158
|
+
return { success: true, stashed };
|
|
159
|
+
}
|
|
160
|
+
// Local branch exists, just checkout
|
|
161
|
+
run(`git checkout ${localName}`, cwd);
|
|
162
|
+
return { success: true, stashed };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
run(`git checkout ${branch}`, cwd);
|
|
166
|
+
return { success: true, stashed };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Initialize a git repository. Throws if already a git repo. */
|
|
170
|
+
export function gitInit(cwd: string): void {
|
|
171
|
+
if (isGitRepo(cwd)) {
|
|
172
|
+
throw new Error("already a git repository");
|
|
173
|
+
}
|
|
174
|
+
run("git init", cwd);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface StashPopResult {
|
|
178
|
+
conflicts: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Pop the most recent stash. */
|
|
182
|
+
export function stashPop(cwd: string): StashPopResult {
|
|
183
|
+
// Check if there are stash entries
|
|
184
|
+
const stashList = tryRun("git stash list", cwd);
|
|
185
|
+
if (!stashList) {
|
|
186
|
+
throw new Error("no stash entries");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
run("git stash pop", cwd);
|
|
191
|
+
return { conflicts: false };
|
|
192
|
+
} catch (err: any) {
|
|
193
|
+
// git stash pop exits non-zero on conflicts but still applies
|
|
194
|
+
const msg: string = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n");
|
|
195
|
+
if (msg.includes("CONFLICT") || msg.includes("conflict") || msg.includes("Merge conflict")) {
|
|
196
|
+
return { conflicts: true };
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry mapping headless child processes to session IDs.
|
|
3
|
+
* Tracks PID + cwd at spawn time, links to sessionId when the bridge connects.
|
|
4
|
+
* Persists entries to disk so a restarted server can clean up orphans.
|
|
5
|
+
*/
|
|
6
|
+
import type { ChildProcess } from "node:child_process";
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
import { readJsonFile, writeJsonFile } from "./json-store.js";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
|
|
12
|
+
/** Default PID file path */
|
|
13
|
+
const DEFAULT_PID_FILE = path.join(os.homedir(), ".pi", "dashboard", "headless-pids.json");
|
|
14
|
+
|
|
15
|
+
/** Max age before an orphan is killed (7 days) */
|
|
16
|
+
const MAX_ORPHAN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
export interface HeadlessEntry {
|
|
19
|
+
pid: number;
|
|
20
|
+
cwd: string;
|
|
21
|
+
process: ChildProcess;
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
spawnedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Serialized format for disk persistence */
|
|
27
|
+
interface PersistedEntry {
|
|
28
|
+
pid: number;
|
|
29
|
+
cwd: string;
|
|
30
|
+
spawnedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PidFileData {
|
|
34
|
+
entries: PersistedEntry[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface HeadlessPidRegistry {
|
|
38
|
+
/** Register a newly spawned headless process. */
|
|
39
|
+
register(pid: number, cwd: string, proc: ChildProcess): void;
|
|
40
|
+
/** Link a session ID to a tracked PID by matching cwd (FIFO). */
|
|
41
|
+
linkSession(sessionId: string, cwd: string): boolean;
|
|
42
|
+
/** Get the PID linked to a session ID. */
|
|
43
|
+
getPid(sessionId: string): number | undefined;
|
|
44
|
+
/** Send SIGTERM to the process linked to a session ID. Returns true if killed. */
|
|
45
|
+
killBySessionId(sessionId: string): boolean;
|
|
46
|
+
/** Remove a tracked process by PID. */
|
|
47
|
+
remove(pid: number): void;
|
|
48
|
+
/** Kill all tracked processes (for server shutdown). */
|
|
49
|
+
killAll(): void;
|
|
50
|
+
/** Number of tracked entries (for testing). */
|
|
51
|
+
size(): number;
|
|
52
|
+
/** Clean up orphan processes from a previous server instance. */
|
|
53
|
+
cleanupOrphans(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HeadlessPidRegistryOptions {
|
|
57
|
+
pidFilePath?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions): HeadlessPidRegistry {
|
|
61
|
+
const entries = new Map<number, HeadlessEntry>();
|
|
62
|
+
const pidFilePath = options?.pidFilePath ?? DEFAULT_PID_FILE;
|
|
63
|
+
|
|
64
|
+
function persist() {
|
|
65
|
+
const data: PidFileData = {
|
|
66
|
+
entries: [...entries.values()].map((e) => ({
|
|
67
|
+
pid: e.pid,
|
|
68
|
+
cwd: e.cwd,
|
|
69
|
+
spawnedAt: new Date(e.spawnedAt).toISOString(),
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
writeJsonFile(pidFilePath, data);
|
|
74
|
+
} catch {
|
|
75
|
+
// Non-fatal — persistence is best-effort
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function loadFromDisk(): PersistedEntry[] {
|
|
80
|
+
const data = readJsonFile<PidFileData>(pidFilePath, { entries: [] });
|
|
81
|
+
return data.entries ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isProcessAlive(pid: number): boolean {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(pid, 0);
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
register(pid: number, cwd: string, proc: ChildProcess) {
|
|
95
|
+
entries.set(pid, { pid, cwd, process: proc, spawnedAt: Date.now() });
|
|
96
|
+
proc.on("exit", () => {
|
|
97
|
+
entries.delete(pid);
|
|
98
|
+
persist();
|
|
99
|
+
});
|
|
100
|
+
persist();
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
linkSession(sessionId: string, cwd: string): boolean {
|
|
104
|
+
for (const entry of entries.values()) {
|
|
105
|
+
if (entry.cwd === cwd && !entry.sessionId) {
|
|
106
|
+
entry.sessionId = sessionId;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
getPid(sessionId: string): number | undefined {
|
|
114
|
+
for (const entry of entries.values()) {
|
|
115
|
+
if (entry.sessionId === sessionId) {
|
|
116
|
+
return entry.pid;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
killBySessionId(sessionId: string): boolean {
|
|
123
|
+
for (const entry of entries.values()) {
|
|
124
|
+
if (entry.sessionId === sessionId) {
|
|
125
|
+
try {
|
|
126
|
+
// On Unix, kill the entire process group (negative PID) so the
|
|
127
|
+
// wrapper shell, sleep, and pi processes are all terminated.
|
|
128
|
+
// On Windows, process groups aren't supported — kill directly.
|
|
129
|
+
const signal = "SIGTERM";
|
|
130
|
+
const pid = process.platform === "win32" ? entry.pid : -entry.pid;
|
|
131
|
+
process.kill(pid, signal);
|
|
132
|
+
entries.delete(entry.pid);
|
|
133
|
+
persist();
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
entries.delete(entry.pid);
|
|
137
|
+
persist();
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
remove(pid: number) {
|
|
146
|
+
entries.delete(pid);
|
|
147
|
+
persist();
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
killAll() {
|
|
151
|
+
const useGroup = process.platform !== "win32";
|
|
152
|
+
for (const [pid] of entries) {
|
|
153
|
+
try {
|
|
154
|
+
process.kill(useGroup ? -pid : pid, "SIGTERM");
|
|
155
|
+
} catch {
|
|
156
|
+
// Process may have already exited
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
entries.clear();
|
|
160
|
+
// Don't persist here — keep disk entries so cleanupOrphans() can
|
|
161
|
+
// reclaim surviving processes after a server restart.
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
size() {
|
|
165
|
+
return entries.size;
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
cleanupOrphans() {
|
|
169
|
+
const persisted = loadFromDisk();
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
|
|
172
|
+
for (const entry of persisted) {
|
|
173
|
+
const spawnedAt = new Date(entry.spawnedAt).getTime();
|
|
174
|
+
const age = now - spawnedAt;
|
|
175
|
+
|
|
176
|
+
if (!isProcessAlive(entry.pid)) {
|
|
177
|
+
// Dead process — skip (will be removed from file on persist)
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (age > MAX_ORPHAN_AGE_MS) {
|
|
182
|
+
// Very old orphan — kill (process group on Unix, direct on Windows)
|
|
183
|
+
try {
|
|
184
|
+
const pid = process.platform === "win32" ? entry.pid : -entry.pid;
|
|
185
|
+
process.kill(pid, "SIGTERM");
|
|
186
|
+
} catch {
|
|
187
|
+
// Already dead
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Alive and not too old — reclaim into registry
|
|
193
|
+
// Create a dummy ChildProcess-like emitter for the entry
|
|
194
|
+
// EventEmitter imported at top level
|
|
195
|
+
const dummyProc = new EventEmitter() as ChildProcess;
|
|
196
|
+
entries.set(entry.pid, {
|
|
197
|
+
pid: entry.pid,
|
|
198
|
+
cwd: entry.cwd,
|
|
199
|
+
process: dummyProc,
|
|
200
|
+
spawnedAt,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
persist();
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-shutdown idle timer with sleep-wake resilience.
|
|
3
|
+
* Shuts down the server when no pi sessions are connected for the configured idle period.
|
|
4
|
+
*/
|
|
5
|
+
import type { PiGateway } from "./pi-gateway.js";
|
|
6
|
+
import type { ServerConfig } from "./server.js";
|
|
7
|
+
|
|
8
|
+
export interface IdleTimer {
|
|
9
|
+
start(): void;
|
|
10
|
+
cancel(): void;
|
|
11
|
+
/** Set the stop callback (must be set before starting) */
|
|
12
|
+
setStopFn(fn: () => Promise<void>): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createIdleTimer(
|
|
16
|
+
config: ServerConfig,
|
|
17
|
+
piGateway: PiGateway,
|
|
18
|
+
): IdleTimer {
|
|
19
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
20
|
+
let stopServer: (() => Promise<void>) | null = null;
|
|
21
|
+
let lastConnectionTimestamp = 0;
|
|
22
|
+
|
|
23
|
+
function start() {
|
|
24
|
+
if (!config.autoShutdown) return;
|
|
25
|
+
cancel();
|
|
26
|
+
idleTimer = setTimeout(async () => {
|
|
27
|
+
const realIdleMs = Date.now() - lastConnectionTimestamp;
|
|
28
|
+
if (piGateway.connectionCount() > 0 || realIdleMs < config.shutdownIdleSeconds * 1000) {
|
|
29
|
+
start();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(`No pi sessions for ${config.shutdownIdleSeconds}s, shutting down...`);
|
|
33
|
+
await stopServer?.();
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}, config.shutdownIdleSeconds * 1000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cancel() {
|
|
39
|
+
if (idleTimer) {
|
|
40
|
+
clearTimeout(idleTimer);
|
|
41
|
+
idleTimer = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
piGateway.onEmpty = () => {
|
|
46
|
+
start();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
piGateway.onConnection = () => {
|
|
50
|
+
lastConnectionTimestamp = Date.now();
|
|
51
|
+
cancel();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
start,
|
|
56
|
+
cancel,
|
|
57
|
+
setStopFn(fn: () => Promise<void>) {
|
|
58
|
+
stopServer = fn;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic JSON file read/write helpers.
|
|
3
|
+
* Uses write-to-tmp + rename pattern to prevent corruption on crash.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read and parse a JSON file. Returns `fallback` if the file doesn't exist or is invalid.
|
|
10
|
+
*/
|
|
11
|
+
export function readJsonFile<T>(filePath: string, fallback: T): T {
|
|
12
|
+
try {
|
|
13
|
+
if (!fs.existsSync(filePath)) return fallback;
|
|
14
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
15
|
+
if (!raw.trim()) return fallback;
|
|
16
|
+
return JSON.parse(raw) as T;
|
|
17
|
+
} catch {
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Atomically write a JSON file (write to .tmp, then rename).
|
|
24
|
+
* Creates parent directories if needed.
|
|
25
|
+
*/
|
|
26
|
+
export function writeJsonFile<T>(filePath: string, data: T): void {
|
|
27
|
+
const dir = path.dirname(filePath);
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
const tmpPath = filePath + ".tmp";
|
|
30
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
|
|
31
|
+
fs.renameSync(tmpPath, filePath);
|
|
32
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network access guard for Fastify routes.
|
|
3
|
+
* Supports loopback, trusted networks (CIDR/wildcard/exact), and authenticated users.
|
|
4
|
+
*/
|
|
5
|
+
import type { FastifyRequest, FastifyReply } from "fastify";
|
|
6
|
+
|
|
7
|
+
const LOOPBACK_ADDRESSES = new Set([
|
|
8
|
+
"127.0.0.1",
|
|
9
|
+
"::1",
|
|
10
|
+
"::ffff:127.0.0.1",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function isLoopback(ip: string): boolean {
|
|
14
|
+
return LOOPBACK_ADDRESSES.has(ip);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns true if the source IP matches any trusted host entry.
|
|
19
|
+
* Supports exact match, wildcard (e.g. "10.0.0.*"), and CIDR notation (e.g. "192.168.1.0/24").
|
|
20
|
+
*/
|
|
21
|
+
export function isBypassedHost(sourceIp: string, bypassHosts: string[]): boolean {
|
|
22
|
+
// Strip IPv4-mapped IPv6 prefix (e.g. ::ffff:192.168.1.1 → 192.168.1.1)
|
|
23
|
+
const ip = sourceIp.startsWith("::ffff:") ? sourceIp.slice(7) : sourceIp;
|
|
24
|
+
for (const entry of bypassHosts) {
|
|
25
|
+
if (entry.includes("/")) {
|
|
26
|
+
if (matchCidr(ip, entry)) return true;
|
|
27
|
+
} else if (entry.includes("*")) {
|
|
28
|
+
const pattern = new RegExp("^" + entry.replace(/\./g, "\\.").replace(/\*/g, "\\d+") + "$");
|
|
29
|
+
if (pattern.test(ip)) return true;
|
|
30
|
+
} else {
|
|
31
|
+
if (ip === entry) return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function matchCidr(ip: string, cidr: string): boolean {
|
|
38
|
+
const [base, bitsStr] = cidr.split("/");
|
|
39
|
+
const bits = parseInt(bitsStr, 10);
|
|
40
|
+
if (isNaN(bits) || bits < 0 || bits > 32) return false;
|
|
41
|
+
const ipNum = ipToNum(ip);
|
|
42
|
+
const baseNum = ipToNum(base);
|
|
43
|
+
if (ipNum === null || baseNum === null) return false;
|
|
44
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
45
|
+
return (ipNum & mask) === (baseNum & mask);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ipToNum(ip: string): number | null {
|
|
49
|
+
const parts = ip.split(".");
|
|
50
|
+
if (parts.length !== 4) return null;
|
|
51
|
+
let num = 0;
|
|
52
|
+
for (const p of parts) {
|
|
53
|
+
const n = parseInt(p, 10);
|
|
54
|
+
if (isNaN(n) || n < 0 || n > 255) return null;
|
|
55
|
+
num = (num << 8) | n;
|
|
56
|
+
}
|
|
57
|
+
return num >>> 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create a network guard that allows loopback, trusted networks, or authenticated requests.
|
|
62
|
+
* Fastify lifecycle guarantees onRequest (auth) runs before preHandler (this guard).
|
|
63
|
+
*/
|
|
64
|
+
export function createNetworkGuard(trustedNetworks: string[]) {
|
|
65
|
+
return async function networkGuard(
|
|
66
|
+
request: FastifyRequest,
|
|
67
|
+
reply: FastifyReply,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
if (isLoopback(request.ip)) return;
|
|
70
|
+
if (trustedNetworks.length > 0 && isBypassedHost(request.ip, trustedNetworks)) return;
|
|
71
|
+
if ((request as any).isAuthenticated) return;
|
|
72
|
+
reply.code(403).send({ success: false, error: "Access denied" });
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert a netmask to CIDR prefix length.
|
|
78
|
+
* E.g. "255.255.255.0" → 24
|
|
79
|
+
*/
|
|
80
|
+
export function netmaskToCidrBits(netmask: string): number {
|
|
81
|
+
const num = ipToNum(netmask);
|
|
82
|
+
if (num === null) return 0;
|
|
83
|
+
let bits = 0;
|
|
84
|
+
let n = num;
|
|
85
|
+
while (n & 0x80000000) {
|
|
86
|
+
bits++;
|
|
87
|
+
n = (n << 1) >>> 0;
|
|
88
|
+
}
|
|
89
|
+
return bits;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compute the network address from an IP and netmask.
|
|
94
|
+
* E.g. ("192.168.1.42", "255.255.255.0") → "192.168.1.0"
|
|
95
|
+
*/
|
|
96
|
+
export function networkAddress(ip: string, netmask: string): string {
|
|
97
|
+
const ipNum = ipToNum(ip);
|
|
98
|
+
const maskNum = ipToNum(netmask);
|
|
99
|
+
if (ipNum === null || maskNum === null) return ip;
|
|
100
|
+
const net = (ipNum & maskNum) >>> 0;
|
|
101
|
+
return [
|
|
102
|
+
(net >>> 24) & 0xff,
|
|
103
|
+
(net >>> 16) & 0xff,
|
|
104
|
+
(net >>> 8) & 0xff,
|
|
105
|
+
net & 0xff,
|
|
106
|
+
].join(".");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Legacy localhost-only guard. Prefer createNetworkGuard() for new code. */
|
|
110
|
+
export async function localhostGuard(
|
|
111
|
+
request: FastifyRequest,
|
|
112
|
+
reply: FastifyReply,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
if (!isLoopback(request.ip)) {
|
|
115
|
+
reply.code(403).send({ success: false, error: "localhost only" });
|
|
116
|
+
}
|
|
117
|
+
}
|