@blackbelt-technology/pi-agent-dashboard 0.2.9 → 0.4.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 +64 -8
- package/README.md +308 -101
- package/docs/architecture.md +515 -16
- package/package.json +14 -7
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
- package/packages/extension/src/__tests__/git-info.test.ts +67 -55
- package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
- package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
- package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
- package/packages/extension/src/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +107 -6
- package/packages/extension/src/command-handler.ts +34 -39
- package/packages/extension/src/dev-build.ts +1 -1
- package/packages/extension/src/git-info.ts +9 -19
- package/packages/extension/src/pi-env.d.ts +1 -0
- package/packages/extension/src/process-scanner.ts +72 -38
- package/packages/extension/src/prompt-expander.ts +25 -4
- package/packages/extension/src/provider-register.ts +304 -16
- package/packages/extension/src/server-auto-start.ts +27 -1
- package/packages/extension/src/server-launcher.ts +71 -27
- package/packages/server/package.json +17 -2
- package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
- package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
- package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
- package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
- package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
- package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
- package/packages/server/src/__tests__/config-api.test.ts +68 -0
- package/packages/server/src/__tests__/cors.test.ts +34 -2
- package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
- package/packages/server/src/__tests__/directory-service.test.ts +234 -8
- package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
- package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
- package/packages/server/src/__tests__/extension-register.test.ts +3 -1
- package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
- package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/git-operations.test.ts +9 -7
- package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
- package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
- package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
- package/packages/server/src/__tests__/home-lock.test.ts +308 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
- package/packages/server/src/__tests__/node-guard.test.ts +85 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
- package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
- package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
- package/packages/server/src/__tests__/process-manager.test.ts +45 -18
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
- package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
- package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
- package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
- package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
- package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
- package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
- package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
- package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
- package/packages/server/src/__tests__/tunnel.test.ts +103 -6
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
- package/packages/server/src/bootstrap-queue.ts +130 -0
- package/packages/server/src/bootstrap-state.ts +131 -0
- package/packages/server/src/browse.ts +108 -9
- package/packages/server/src/browser-gateway.ts +16 -3
- package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
- package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
- package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
- package/packages/server/src/cli.ts +256 -32
- package/packages/server/src/config-api.ts +16 -0
- package/packages/server/src/directory-service.ts +270 -39
- package/packages/server/src/editor-detection.ts +12 -9
- package/packages/server/src/editor-manager.ts +39 -5
- package/packages/server/src/editor-pid-registry.ts +199 -0
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +16 -20
- package/packages/server/src/home-lock-release.ts +72 -0
- package/packages/server/src/home-lock.ts +389 -0
- package/packages/server/src/node-guard.ts +52 -0
- package/packages/server/src/npm-search-proxy.ts +71 -0
- package/packages/server/src/openspec-tasks.ts +158 -0
- package/packages/server/src/package-manager-wrapper.ts +225 -34
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +172 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- package/packages/server/src/pi-resource-scanner.ts +5 -8
- package/packages/server/src/pi-version-skew.ts +196 -0
- package/packages/server/src/preferences-store.ts +17 -3
- package/packages/server/src/process-manager.ts +403 -222
- package/packages/server/src/provider-probe.ts +234 -0
- package/packages/server/src/restart-helper.ts +130 -0
- package/packages/server/src/routes/bootstrap-routes.ts +88 -0
- package/packages/server/src/routes/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +107 -1
- package/packages/server/src/routes/pi-core-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +12 -10
- package/packages/server/src/routes/provider-routes.ts +55 -2
- package/packages/server/src/routes/recommended-routes.ts +225 -0
- package/packages/server/src/routes/system-routes.ts +30 -34
- package/packages/server/src/routes/tool-routes.ts +153 -0
- package/packages/server/src/server-pid.ts +5 -9
- package/packages/server/src/server.ts +363 -26
- package/packages/server/src/session-api.ts +77 -8
- package/packages/server/src/session-bootstrap.ts +17 -3
- package/packages/server/src/session-diff.ts +21 -21
- package/packages/server/src/terminal-manager.ts +65 -20
- package/packages/server/src/test-env-guard.ts +26 -0
- package/packages/server/src/test-support/test-server.ts +63 -0
- package/packages/server/src/tunnel.ts +172 -34
- package/packages/shared/package.json +10 -3
- package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
- package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
- package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
- package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
- package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
- package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
- package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
- package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
- package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
- package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
- package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
- package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
- package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
- package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
- package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
- package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
- package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
- package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
- package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
- package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
- package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
- package/packages/shared/src/__tests__/config.test.ts +59 -3
- package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
- package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
- package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
- package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
- package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
- package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
- package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
- package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
- package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
- package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
- package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
- package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
- package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
- package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
- package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
- package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
- package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
- package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
- package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
- package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
- package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
- package/packages/shared/src/bootstrap-install.ts +212 -0
- package/packages/shared/src/bridge-register.ts +87 -20
- package/packages/shared/src/browser-protocol.ts +93 -1
- package/packages/shared/src/config.ts +87 -15
- package/packages/shared/src/managed-paths.ts +31 -4
- package/packages/shared/src/openspec-poller.ts +71 -49
- package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
- package/packages/shared/src/platform/commands.ts +100 -0
- package/packages/shared/src/platform/detached-spawn.ts +305 -0
- package/packages/shared/src/platform/exec.ts +220 -0
- package/packages/shared/src/platform/git.ts +155 -0
- package/packages/shared/src/platform/index.ts +15 -0
- package/packages/shared/src/platform/npm.ts +162 -0
- package/packages/shared/src/platform/openspec.ts +91 -0
- package/packages/shared/src/platform/paths.ts +276 -0
- package/packages/shared/src/platform/process-identify.ts +126 -0
- package/packages/shared/src/platform/process-scan.ts +94 -0
- package/packages/shared/src/platform/process.ts +168 -0
- package/packages/shared/src/platform/runner.ts +369 -0
- package/packages/shared/src/platform/shell.ts +44 -0
- package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
- package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
- package/packages/shared/src/recommended-extensions.ts +196 -0
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +97 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/source-matching.ts +126 -0
- package/packages/shared/src/test-support/setup-home.ts +74 -0
- package/packages/shared/src/tool-registry/definitions.ts +342 -0
- package/packages/shared/src/tool-registry/index.ts +56 -0
- package/packages/shared/src/tool-registry/overrides.ts +118 -0
- package/packages/shared/src/tool-registry/registry.ts +262 -0
- package/packages/shared/src/tool-registry/strategies.ts +198 -0
- package/packages/shared/src/tool-registry/types.ts +180 -0
- package/packages/shared/src/types.ts +7 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import * as childProcess from "node:child_process";
|
|
5
|
+
|
|
6
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
execSync: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
});
|
|
4
13
|
|
|
5
14
|
vi.mock("node:fs", async (importOriginal) => {
|
|
6
15
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
@@ -38,6 +47,8 @@ import {
|
|
|
38
47
|
removeZrokPid,
|
|
39
48
|
cleanupStaleZrok,
|
|
40
49
|
getTunnelStatus,
|
|
50
|
+
releaseShare,
|
|
51
|
+
scavengeOrphanZrokProcesses,
|
|
41
52
|
_resetBinaryCache,
|
|
42
53
|
_setBinaryAvailable,
|
|
43
54
|
} from "../tunnel.js";
|
|
@@ -149,30 +160,32 @@ describe("PID file helpers", () => {
|
|
|
149
160
|
});
|
|
150
161
|
|
|
151
162
|
describe("cleanupStaleZrok", () => {
|
|
152
|
-
it("should do nothing when no PID file exists", () => {
|
|
163
|
+
it("should do nothing when no PID file exists", async () => {
|
|
153
164
|
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
154
165
|
throw new Error("ENOENT");
|
|
155
166
|
});
|
|
156
167
|
const killSpy = vi.spyOn(process, "kill");
|
|
157
168
|
|
|
158
|
-
cleanupStaleZrok();
|
|
169
|
+
await cleanupStaleZrok();
|
|
159
170
|
|
|
160
171
|
expect(killSpy).not.toHaveBeenCalled();
|
|
161
172
|
});
|
|
162
173
|
|
|
163
|
-
it("should kill running stale process and remove PID file", () => {
|
|
174
|
+
it("should kill running stale process and remove PID file", async () => {
|
|
164
175
|
vi.mocked(fs.readFileSync).mockReturnValue("99999\n");
|
|
165
176
|
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
|
|
166
177
|
vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
|
|
167
178
|
|
|
168
|
-
cleanupStaleZrok
|
|
179
|
+
// cleanupStaleZrok became async when it moved to platform/process's
|
|
180
|
+
// killProcess (SIGTERM+grace+SIGKILL orchestration).
|
|
181
|
+
await cleanupStaleZrok();
|
|
169
182
|
|
|
170
183
|
expect(killSpy).toHaveBeenCalledWith(99999, 0);
|
|
171
184
|
expect(killSpy).toHaveBeenCalledWith(99999, "SIGTERM");
|
|
172
185
|
expect(fs.unlinkSync).toHaveBeenCalled();
|
|
173
186
|
});
|
|
174
187
|
|
|
175
|
-
it("should just remove PID file if process is not running", () => {
|
|
188
|
+
it("should just remove PID file if process is not running", async () => {
|
|
176
189
|
vi.mocked(fs.readFileSync).mockReturnValue("99999\n");
|
|
177
190
|
const killSpy = vi.spyOn(process, "kill").mockImplementation((_pid: number, signal?: string | number) => {
|
|
178
191
|
if (signal === 0) throw new Error("ESRCH");
|
|
@@ -180,13 +193,97 @@ describe("cleanupStaleZrok", () => {
|
|
|
180
193
|
});
|
|
181
194
|
vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
|
|
182
195
|
|
|
183
|
-
cleanupStaleZrok();
|
|
196
|
+
await cleanupStaleZrok();
|
|
184
197
|
|
|
185
198
|
expect(killSpy).toHaveBeenCalledTimes(1);
|
|
186
199
|
expect(fs.unlinkSync).toHaveBeenCalled();
|
|
187
200
|
});
|
|
188
201
|
});
|
|
189
202
|
|
|
203
|
+
describe("createTunnel mutex", () => {
|
|
204
|
+
it("should return the same promise when called concurrently", async () => {
|
|
205
|
+
// Binary unavailable → both calls resolve null fast, same promise instance.
|
|
206
|
+
_setBinaryAvailable(false);
|
|
207
|
+
const { createTunnel } = await import("../tunnel.js");
|
|
208
|
+
const p1 = createTunnel(8000);
|
|
209
|
+
const p2 = createTunnel(8000);
|
|
210
|
+
// With binary unavailable the inner resolves synchronously-ish with null.
|
|
211
|
+
// Both should settle identically without spawning anything.
|
|
212
|
+
await expect(p1).resolves.toBeNull();
|
|
213
|
+
await expect(p2).resolves.toBeNull();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("releaseShare", () => {
|
|
218
|
+
it("should call `zrok release <token>` and return true on success", () => {
|
|
219
|
+
vi.mocked(childProcess.execSync).mockReturnValue(Buffer.from(""));
|
|
220
|
+
const ok = releaseShare("abc123");
|
|
221
|
+
expect(ok).toBe(true);
|
|
222
|
+
expect(childProcess.execSync).toHaveBeenCalledWith(
|
|
223
|
+
expect.stringContaining("zrok release abc123"),
|
|
224
|
+
expect.any(Object),
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should return false when zrok release fails (best-effort, non-throwing)", () => {
|
|
229
|
+
vi.mocked(childProcess.execSync).mockImplementation(() => {
|
|
230
|
+
throw new Error("release failed");
|
|
231
|
+
});
|
|
232
|
+
expect(releaseShare("abc123")).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should return false for empty token without invoking zrok", () => {
|
|
236
|
+
const ok = releaseShare("");
|
|
237
|
+
expect(ok).toBe(false);
|
|
238
|
+
expect(childProcess.execSync).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("scavengeOrphanZrokProcesses", () => {
|
|
243
|
+
it("should kill zrok processes bound to the given port", () => {
|
|
244
|
+
// Simulate `ps` returning two zrok share processes: one matching, one not.
|
|
245
|
+
vi.mocked(childProcess.execSync).mockReturnValue(
|
|
246
|
+
Buffer.from(
|
|
247
|
+
[
|
|
248
|
+
"12345 zrok share reserved aaa --headless --override-endpoint http://localhost:8000",
|
|
249
|
+
"12346 zrok share reserved bbb --headless --override-endpoint http://localhost:9000",
|
|
250
|
+
"12347 some-other-process",
|
|
251
|
+
].join("\n"),
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
|
|
255
|
+
|
|
256
|
+
const killed = scavengeOrphanZrokProcesses(8000);
|
|
257
|
+
|
|
258
|
+
expect(killed).toEqual([12345]);
|
|
259
|
+
// Negative PID targets the whole process group on Unix (killPidWithGroup's
|
|
260
|
+
// contract); positive PID on Windows. Match whichever platform we're on.
|
|
261
|
+
const expectedPid = process.platform === "win32" ? 12345 : -12345;
|
|
262
|
+
expect(killSpy).toHaveBeenCalledWith(expectedPid, "SIGTERM");
|
|
263
|
+
expect(killSpy).not.toHaveBeenCalledWith(12346, expect.anything());
|
|
264
|
+
expect(killSpy).not.toHaveBeenCalledWith(-12346, expect.anything());
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should return empty array on ps failure", () => {
|
|
268
|
+
vi.mocked(childProcess.execSync).mockImplementation(() => {
|
|
269
|
+
throw new Error("ps failed");
|
|
270
|
+
});
|
|
271
|
+
expect(scavengeOrphanZrokProcesses(8000)).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should skip self (current process PID)", () => {
|
|
275
|
+
vi.mocked(childProcess.execSync).mockReturnValue(
|
|
276
|
+
Buffer.from(`${process.pid} zrok share reserved zzz --override-endpoint http://localhost:8000`),
|
|
277
|
+
);
|
|
278
|
+
const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
|
|
279
|
+
|
|
280
|
+
const killed = scavengeOrphanZrokProcesses(8000);
|
|
281
|
+
|
|
282
|
+
expect(killed).toEqual([]);
|
|
283
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
190
287
|
describe("getTunnelStatus", () => {
|
|
191
288
|
it("should return unavailable when binary not available", () => {
|
|
192
289
|
_setBinaryAvailable(false);
|
|
@@ -53,7 +53,12 @@ describe("WS ping/pong", () => {
|
|
|
53
53
|
ws.close();
|
|
54
54
|
}, 10000);
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// TODO(fix-failing-tests-followup): pi-gateway now keeps sessions alive when
|
|
57
|
+
// the underlying TCP socket is still writable ("ping: N misses but TCP alive,
|
|
58
|
+
// keeping session"), so pausing the ws socket no longer produces a terminate.
|
|
59
|
+
// Requires reworking the test to close the socket or mock the TCP writability
|
|
60
|
+
// probe. See openspec/changes/fix-failing-tests/tasks.md §7.
|
|
61
|
+
it.skip("should terminate connection when client stops responding to pings", async () => {
|
|
57
62
|
const sessionManager = createMemorySessionManager();
|
|
58
63
|
gateway = createPiGateway(sessionManager, {
|
|
59
64
|
heartbeatTimeout: SHORT_HB,
|
|
@@ -82,7 +87,10 @@ describe("WS ping/pong", () => {
|
|
|
82
87
|
expect(sessionManager.get("ping-dead")!.status).toBe("ended");
|
|
83
88
|
}, 10000);
|
|
84
89
|
|
|
85
|
-
|
|
90
|
+
// TODO(fix-failing-tests-followup): same root cause as the previous skip —
|
|
91
|
+
// onEmpty is only invoked after a terminate, which no longer fires in this
|
|
92
|
+
// test's conditions. See §7.
|
|
93
|
+
it.skip("should call onEmpty after ping timeout terminates last connection", async () => {
|
|
86
94
|
const sessionManager = createMemorySessionManager();
|
|
87
95
|
gateway = createPiGateway(sessionManager, {
|
|
88
96
|
heartbeatTimeout: SHORT_HB,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { _resetWslTmuxCacheForTests } from "../process-manager.js";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Structural test: the WSL-tmux probe MUST have a module-scoped cache so it's
|
|
12
|
+
* invoked at most once per server lifetime. Without this cache, users on
|
|
13
|
+
* Windows without `wt.exe` pay the full WSL VM cold-start cost (1.5–30 s) on
|
|
14
|
+
* every + Session click.
|
|
15
|
+
*
|
|
16
|
+
* We assert the cache exists by source inspection (tight, deterministic) and
|
|
17
|
+
* that a reset helper is exported for tests.
|
|
18
|
+
*/
|
|
19
|
+
describe("WSL-tmux probe cache invariant", () => {
|
|
20
|
+
const src = readFileSync(
|
|
21
|
+
path.resolve(__dirname, "../process-manager.ts"),
|
|
22
|
+
"utf-8",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
it("process-manager.ts declares _wslTmuxAvailabilityCache", () => {
|
|
26
|
+
expect(src).toMatch(/let\s+_wslTmuxAvailabilityCache\s*:\s*boolean\s*\|\s*null\s*=\s*null/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("isWslTmuxAvailable() returns the cached value when non-null", () => {
|
|
30
|
+
expect(src).toMatch(/if\s*\(\s*_wslTmuxAvailabilityCache\s*!==\s*null\s*\)\s*return\s+_wslTmuxAvailabilityCache/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fallback-log fires at most once per server run", () => {
|
|
34
|
+
expect(src).toMatch(/_wslFallbackLogged\s*=\s*true/);
|
|
35
|
+
expect(src).toMatch(/if\s*\(\s*!_wslTmuxAvailabilityCache\s*&&\s*!_wslFallbackLogged\s*\)/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("exports a cache-reset helper for tests", () => {
|
|
39
|
+
expect(typeof _resetWslTmuxCacheForTests).toBe("function");
|
|
40
|
+
// reset is idempotent — safe to call repeatedly
|
|
41
|
+
_resetWslTmuxCacheForTests();
|
|
42
|
+
_resetWslTmuxCacheForTests();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory queue for pi-dependent operations deferred during bootstrap
|
|
3
|
+
* install. When `bootstrapState.status === "installing"`, callers should
|
|
4
|
+
* enqueue their handler and return a 202 Accepted with the ticketId.
|
|
5
|
+
* When status transitions to "ready", `flushAll()` runs every queued
|
|
6
|
+
* handler sequentially in enqueue order.
|
|
7
|
+
*
|
|
8
|
+
* Queue is process-local and NOT persisted. If the dashboard crashes
|
|
9
|
+
* mid-install, queued requests are lost — documented as a known
|
|
10
|
+
* limitation in design.md §16.2.
|
|
11
|
+
*
|
|
12
|
+
* See change: unified-bootstrap-install.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export interface QueuedTicket<T> {
|
|
17
|
+
ticketId: string;
|
|
18
|
+
/**
|
|
19
|
+
* Resolves when the queued handler runs (or rejects if it throws).
|
|
20
|
+
* Call sites can await this when they want to synchronously return
|
|
21
|
+
* the eventual result — but 202-Accepted flows MUST NOT await, they
|
|
22
|
+
* return the ticketId to the client immediately.
|
|
23
|
+
*/
|
|
24
|
+
result: Promise<T>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BootstrapQueue {
|
|
28
|
+
enqueue<T>(handler: () => Promise<T>): QueuedTicket<T>;
|
|
29
|
+
flushAll(): Promise<void>;
|
|
30
|
+
/** Number of currently pending tickets. */
|
|
31
|
+
size(): number;
|
|
32
|
+
/** Drop all pending tickets without running them (used at shutdown). */
|
|
33
|
+
clear(reason?: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Register a listener invoked after each ticket runs (success or
|
|
36
|
+
* failure). The server wires this to a `bootstrap_ticket_complete`
|
|
37
|
+
* WS broadcast so browser clients can correlate the outcome of a
|
|
38
|
+
* 202-accepted request via their stored ticketId.
|
|
39
|
+
* See change: unified-bootstrap-install.
|
|
40
|
+
*/
|
|
41
|
+
onTicketComplete(
|
|
42
|
+
listener: (evt: { ticketId: string; success: boolean; error?: string }) => void,
|
|
43
|
+
): () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PendingEntry {
|
|
47
|
+
ticketId: string;
|
|
48
|
+
run: () => Promise<void>;
|
|
49
|
+
/** Reject the caller's `result` promise. Called by `clear()` to
|
|
50
|
+
* drain tickets at shutdown. */
|
|
51
|
+
reject: (err: unknown) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createBootstrapQueue(): BootstrapQueue {
|
|
55
|
+
const pending: PendingEntry[] = [];
|
|
56
|
+
const listeners = new Set<
|
|
57
|
+
(evt: { ticketId: string; success: boolean; error?: string }) => void
|
|
58
|
+
>();
|
|
59
|
+
|
|
60
|
+
function notify(evt: { ticketId: string; success: boolean; error?: string }): void {
|
|
61
|
+
for (const l of listeners) {
|
|
62
|
+
try {
|
|
63
|
+
l(evt);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error("[bootstrap-queue] ticket-complete listener threw:", err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
enqueue<T>(handler: () => Promise<T>): QueuedTicket<T> {
|
|
72
|
+
const ticketId = randomUUID();
|
|
73
|
+
let resolve!: (value: T) => void;
|
|
74
|
+
let reject!: (err: unknown) => void;
|
|
75
|
+
const result = new Promise<T>((res, rej) => {
|
|
76
|
+
resolve = res;
|
|
77
|
+
reject = rej;
|
|
78
|
+
});
|
|
79
|
+
pending.push({
|
|
80
|
+
ticketId,
|
|
81
|
+
reject,
|
|
82
|
+
run: async () => {
|
|
83
|
+
try {
|
|
84
|
+
const value = await handler();
|
|
85
|
+
resolve(value);
|
|
86
|
+
notify({ ticketId, success: true });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
reject(err);
|
|
89
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
notify({ ticketId, success: false, error: message });
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return { ticketId, result };
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async flushAll(): Promise<void> {
|
|
98
|
+
while (pending.length > 0) {
|
|
99
|
+
const entry = pending.shift();
|
|
100
|
+
if (!entry) break;
|
|
101
|
+
try {
|
|
102
|
+
await entry.run();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Handler errors propagate via the ticket's `result` promise;
|
|
105
|
+
// they should never reach here unless there's a bug in `run`.
|
|
106
|
+
console.error(`[bootstrap-queue] ticket ${entry.ticketId} run threw:`, err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
size() {
|
|
112
|
+
return pending.length;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
clear(reason = "queue cleared") {
|
|
116
|
+
const drained = pending.splice(0, pending.length);
|
|
117
|
+
for (const entry of drained) {
|
|
118
|
+
// Reject the caller's `result` promise directly and broadcast the
|
|
119
|
+
// completion so any browser holding the ticketId learns the
|
|
120
|
+
// outcome.
|
|
121
|
+
entry.reject(new Error(reason));
|
|
122
|
+
notify({ ticketId: entry.ticketId, success: false, error: reason });
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
onTicketComplete(listener) {
|
|
126
|
+
listeners.add(listener);
|
|
127
|
+
return () => listeners.delete(listener);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory bootstrap state store for the dashboard server.
|
|
3
|
+
*
|
|
4
|
+
* Tracks the degraded-mode status during first-run pi install, upgrade
|
|
5
|
+
* operations, and version-skew detection. Subscribers (browser gateway,
|
|
6
|
+
* CLI progress printer) receive a snapshot on every `set()` call.
|
|
7
|
+
*
|
|
8
|
+
* See change: unified-bootstrap-install.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type BootstrapStatus = "ready" | "installing" | "failed";
|
|
12
|
+
|
|
13
|
+
export interface BootstrapProgress {
|
|
14
|
+
/** Package / phase being processed (e.g. "pi-coding-agent", "bridge-register"). */
|
|
15
|
+
step: string;
|
|
16
|
+
/** Optional completion percentage (0..100). */
|
|
17
|
+
pct?: number;
|
|
18
|
+
/** Last line of npm output or other streaming context. */
|
|
19
|
+
output?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BootstrapError {
|
|
23
|
+
message: string;
|
|
24
|
+
stack?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BootstrapVersions {
|
|
28
|
+
pi?: string;
|
|
29
|
+
openspec?: string;
|
|
30
|
+
tsx?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BootstrapCompatibility {
|
|
34
|
+
minimum: string;
|
|
35
|
+
recommended: string;
|
|
36
|
+
/** null = no upper bound enforced yet. */
|
|
37
|
+
maximum: string | null;
|
|
38
|
+
/** Current resolved pi version, or undefined when pi is unresolved. */
|
|
39
|
+
current?: string;
|
|
40
|
+
/** Hint that the user should upgrade pi (below recommended). */
|
|
41
|
+
upgradeRecommended?: boolean;
|
|
42
|
+
/** Hint that the user should upgrade the dashboard itself (above maximum). */
|
|
43
|
+
upgradeDashboard?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BootstrapState {
|
|
47
|
+
status: BootstrapStatus;
|
|
48
|
+
progress?: BootstrapProgress;
|
|
49
|
+
error?: BootstrapError;
|
|
50
|
+
version?: BootstrapVersions;
|
|
51
|
+
compatibility?: BootstrapCompatibility;
|
|
52
|
+
/** Set when `registerBridgeExtension` fails after a successful install. */
|
|
53
|
+
bridgeRegistrationError?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type BootstrapListener = (state: BootstrapState) => void;
|
|
57
|
+
|
|
58
|
+
export interface BootstrapStateStore {
|
|
59
|
+
get(): BootstrapState;
|
|
60
|
+
/**
|
|
61
|
+
* Merge `partial` into the current state. Passing `undefined` for a
|
|
62
|
+
* key explicitly clears it (e.g. `set({ progress: undefined })` removes
|
|
63
|
+
* the progress line after completion). Broadcasts to all subscribers.
|
|
64
|
+
*/
|
|
65
|
+
set(partial: Partial<BootstrapState>): void;
|
|
66
|
+
subscribe(listener: BootstrapListener): () => void;
|
|
67
|
+
/** Clear all listeners (used in tests + server shutdown). */
|
|
68
|
+
dispose(): void;
|
|
69
|
+
/**
|
|
70
|
+
* Record the package list used by the most recent `bootstrapInstall`
|
|
71
|
+
* call. Used by `POST /api/bootstrap/retry` to re-run the exact failed
|
|
72
|
+
* set rather than a hard-coded default. Not part of the WS-broadcast
|
|
73
|
+
* snapshot — it's purely side-channel metadata for the server.
|
|
74
|
+
* See change: unified-bootstrap-install (verification follow-up).
|
|
75
|
+
*/
|
|
76
|
+
setLastInstallPackages(packages: readonly string[]): void;
|
|
77
|
+
/** Read the last install set. Returns a fresh copy. */
|
|
78
|
+
getLastInstallPackages(): string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a fresh bootstrap state store. `initial` is merged over the
|
|
83
|
+
* default `{ status: "ready" }`.
|
|
84
|
+
*/
|
|
85
|
+
export function createBootstrapState(
|
|
86
|
+
initial?: Partial<BootstrapState>,
|
|
87
|
+
): BootstrapStateStore {
|
|
88
|
+
let state: BootstrapState = { status: "ready", ...initial };
|
|
89
|
+
let lastInstallPackages: string[] = [];
|
|
90
|
+
const listeners = new Set<BootstrapListener>();
|
|
91
|
+
|
|
92
|
+
function notify(): void {
|
|
93
|
+
const snapshot = { ...state };
|
|
94
|
+
for (const l of listeners) {
|
|
95
|
+
try {
|
|
96
|
+
l(snapshot);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Listener errors are non-fatal — log but continue.
|
|
99
|
+
console.error("[bootstrap-state] listener threw:", err);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
get() {
|
|
106
|
+
return { ...state };
|
|
107
|
+
},
|
|
108
|
+
set(partial) {
|
|
109
|
+
// Merge: explicit `undefined` in partial clears the field.
|
|
110
|
+
state = { ...state, ...partial } as BootstrapState;
|
|
111
|
+
// Strip keys whose value is undefined to keep the snapshot tidy.
|
|
112
|
+
for (const key of Object.keys(partial) as (keyof BootstrapState)[]) {
|
|
113
|
+
if (partial[key] === undefined) delete state[key];
|
|
114
|
+
}
|
|
115
|
+
notify();
|
|
116
|
+
},
|
|
117
|
+
subscribe(listener) {
|
|
118
|
+
listeners.add(listener);
|
|
119
|
+
return () => listeners.delete(listener);
|
|
120
|
+
},
|
|
121
|
+
dispose() {
|
|
122
|
+
listeners.clear();
|
|
123
|
+
},
|
|
124
|
+
setLastInstallPackages(packages) {
|
|
125
|
+
lastInstallPackages = [...packages];
|
|
126
|
+
},
|
|
127
|
+
getLastInstallPackages() {
|
|
128
|
+
return [...lastInstallPackages];
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -5,16 +5,40 @@ import fs from "node:fs/promises";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import type { BrowseEntry, BrowseResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
8
|
+
import { isFilesystemRoot } from "@blackbelt-technology/pi-dashboard-shared/platform/paths.js";
|
|
8
9
|
|
|
9
10
|
const MAX_ENTRIES = 200;
|
|
11
|
+
const WORD_BOUNDARY_CHARS = new Set(["-", "_", ".", " ", "/"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compute the rank tier for a name against a lowercase query.
|
|
15
|
+
* Lower tier = better match.
|
|
16
|
+
* 0: exact match
|
|
17
|
+
* 1: prefix match
|
|
18
|
+
* 2: word-boundary substring (preceded by -, _, ., space, /)
|
|
19
|
+
* 3: plain substring
|
|
20
|
+
* 4: no match (filter out)
|
|
21
|
+
*/
|
|
22
|
+
function rankTier(name: string, qLower: string): number {
|
|
23
|
+
const nameLower = name.toLowerCase();
|
|
24
|
+
if (nameLower === qLower) return 0;
|
|
25
|
+
if (nameLower.startsWith(qLower)) return 1;
|
|
26
|
+
const idx = nameLower.indexOf(qLower);
|
|
27
|
+
if (idx < 0) return 4;
|
|
28
|
+
const prev = nameLower[idx - 1];
|
|
29
|
+
if (idx === 0 || (prev !== undefined && WORD_BOUNDARY_CHARS.has(prev))) return 2;
|
|
30
|
+
return 3;
|
|
31
|
+
}
|
|
10
32
|
|
|
11
33
|
/**
|
|
12
34
|
* List subdirectories of a given path.
|
|
13
35
|
* Excludes hidden directories (starting with ".").
|
|
14
36
|
* Detects .git and .pi subdirectories for visual hints.
|
|
15
|
-
*
|
|
37
|
+
* When `q` is non-empty, filters by case-insensitive substring and ranks
|
|
38
|
+
* (exact → prefix → word-boundary → substring), alphabetical within tier.
|
|
39
|
+
* Caps at 200 entries AFTER filtering/ranking.
|
|
16
40
|
*/
|
|
17
|
-
export async function listDirectories(dirPath?: string): Promise<BrowseResult> {
|
|
41
|
+
export async function listDirectories(dirPath?: string, q?: string): Promise<BrowseResult> {
|
|
18
42
|
const resolved = dirPath ?? os.homedir();
|
|
19
43
|
|
|
20
44
|
// Verify the directory exists and is a directory
|
|
@@ -26,14 +50,28 @@ export async function listDirectories(dirPath?: string): Promise<BrowseResult> {
|
|
|
26
50
|
const rawEntries = await fs.readdir(resolved, { withFileTypes: true });
|
|
27
51
|
|
|
28
52
|
// Filter: directories only, no hidden dirs
|
|
29
|
-
|
|
53
|
+
let dirs = rawEntries.filter(
|
|
30
54
|
(e) => e.isDirectory() && !e.name.startsWith(".")
|
|
31
55
|
);
|
|
32
56
|
|
|
33
|
-
//
|
|
34
|
-
|
|
57
|
+
// Apply optional substring filter + tiered ranking
|
|
58
|
+
const qTrim = (q ?? "").trim();
|
|
59
|
+
if (qTrim) {
|
|
60
|
+
const qLower = qTrim.toLowerCase();
|
|
61
|
+
const ranked = dirs
|
|
62
|
+
.map((d) => ({ d, tier: rankTier(d.name, qLower) }))
|
|
63
|
+
.filter((x) => x.tier < 4);
|
|
64
|
+
ranked.sort((a, b) => {
|
|
65
|
+
if (a.tier !== b.tier) return a.tier - b.tier;
|
|
66
|
+
return a.d.name.toLowerCase().localeCompare(b.d.name.toLowerCase());
|
|
67
|
+
});
|
|
68
|
+
dirs = ranked.map((x) => x.d);
|
|
69
|
+
} else {
|
|
70
|
+
// Alphabetical, case-insensitive
|
|
71
|
+
dirs.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
|
72
|
+
}
|
|
35
73
|
|
|
36
|
-
// Cap at MAX_ENTRIES
|
|
74
|
+
// Cap at MAX_ENTRIES (AFTER filtering/ranking)
|
|
37
75
|
const capped = dirs.slice(0, MAX_ENTRIES);
|
|
38
76
|
|
|
39
77
|
// Build entries with isGit/isPi detection
|
|
@@ -48,8 +86,69 @@ export async function listDirectories(dirPath?: string): Promise<BrowseResult> {
|
|
|
48
86
|
})
|
|
49
87
|
);
|
|
50
88
|
|
|
51
|
-
// Parent: null for root
|
|
52
|
-
|
|
89
|
+
// Parent: null for any filesystem root (`/`, `C:\`, `\\server\share\`).
|
|
90
|
+
// Previously this was `resolved === "/"`, which only recognized the Unix
|
|
91
|
+
// root — on Windows `path.dirname("B:\\")` returns `"B:\\"`, so the
|
|
92
|
+
// picker showed a useless `..` entry at drive roots.
|
|
93
|
+
// See change: platform-path-normalization.
|
|
94
|
+
const parent = isFilesystemRoot(resolved) ? null : path.dirname(resolved);
|
|
95
|
+
|
|
96
|
+
return { entries, parent, current: resolved, platform: process.platform };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate a directory name for mkdir.
|
|
101
|
+
* Returns null if valid, or an error message string if invalid.
|
|
102
|
+
*/
|
|
103
|
+
export function validateMkdirName(name: string): string | null {
|
|
104
|
+
if (typeof name !== "string") return "invalid name";
|
|
105
|
+
if (name.length === 0) return "invalid name";
|
|
106
|
+
// No leading/trailing whitespace (also rejects whitespace-only)
|
|
107
|
+
if (name !== name.trim()) return "invalid name";
|
|
108
|
+
if (name === "." || name === "..") return "invalid name";
|
|
109
|
+
if (name.includes("/") || name.includes("\\")) return "invalid name";
|
|
110
|
+
if (name.includes("\0")) return "invalid name";
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create a new directory under `parent` named `name`.
|
|
116
|
+
* Validates inputs, verifies parent exists and is a directory,
|
|
117
|
+
* and creates the target non-recursively (fails if it already exists).
|
|
118
|
+
* Returns the absolute path of the created directory.
|
|
119
|
+
*
|
|
120
|
+
* Throws Error with one of these messages:
|
|
121
|
+
* - "invalid name"
|
|
122
|
+
* - "parent not found"
|
|
123
|
+
* - "parent is not a directory"
|
|
124
|
+
* - "already exists"
|
|
125
|
+
* - or an OS error message for other failures.
|
|
126
|
+
*/
|
|
127
|
+
export async function createDirectory(parent: string, name: string): Promise<string> {
|
|
128
|
+
const nameErr = validateMkdirName(name);
|
|
129
|
+
if (nameErr) throw new Error(nameErr);
|
|
130
|
+
|
|
131
|
+
if (typeof parent !== "string" || parent.length === 0 || !path.isAbsolute(parent)) {
|
|
132
|
+
throw new Error("parent not found");
|
|
133
|
+
}
|
|
53
134
|
|
|
54
|
-
|
|
135
|
+
let parentStat;
|
|
136
|
+
try {
|
|
137
|
+
parentStat = await fs.stat(parent);
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error("parent not found");
|
|
140
|
+
}
|
|
141
|
+
if (!parentStat.isDirectory()) {
|
|
142
|
+
throw new Error("parent is not a directory");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const target = path.join(parent, name);
|
|
146
|
+
try {
|
|
147
|
+
await fs.mkdir(target, { recursive: false });
|
|
148
|
+
} catch (err: unknown) {
|
|
149
|
+
const e = err as NodeJS.ErrnoException;
|
|
150
|
+
if (e?.code === "EEXIST") throw new Error("already exists");
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
return target;
|
|
55
154
|
}
|
|
@@ -240,8 +240,16 @@ export function createBrowserGateway(
|
|
|
240
240
|
|
|
241
241
|
|
|
242
242
|
ws.on("message", async (raw) => {
|
|
243
|
+
// Malformed (non-JSON) frames are silently dropped. Only frame-parse
|
|
244
|
+
// errors are swallowed here — handler exceptions are logged below so
|
|
245
|
+
// real bugs (e.g. node-pty spawn failures) are not silently hidden.
|
|
246
|
+
let msg: BrowserToServerMessage;
|
|
247
|
+
try {
|
|
248
|
+
msg = JSON.parse(raw.toString()) as BrowserToServerMessage;
|
|
249
|
+
} catch {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
243
252
|
try {
|
|
244
|
-
const msg = JSON.parse(raw.toString()) as BrowserToServerMessage;
|
|
245
253
|
const ctx: BrowserHandlerContext = {
|
|
246
254
|
ws, sessionManager, eventStore, piGateway,
|
|
247
255
|
pendingForkRegistry, sessionOrderManager, preferencesStore,
|
|
@@ -432,8 +440,13 @@ export function createBrowserGateway(
|
|
|
432
440
|
handlePiGatewayForward(msg, ctx);
|
|
433
441
|
break;
|
|
434
442
|
}
|
|
435
|
-
} catch {
|
|
436
|
-
|
|
443
|
+
} catch (err) {
|
|
444
|
+
const type = (msg as { type?: string } | undefined)?.type ?? "unknown";
|
|
445
|
+
console.error(
|
|
446
|
+
`[browser-gw] handler error type=${type}:`,
|
|
447
|
+
err,
|
|
448
|
+
);
|
|
449
|
+
// Connection intentionally remains open so subsequent messages are still processed.
|
|
437
450
|
}
|
|
438
451
|
});
|
|
439
452
|
|