Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 b5463915f5 fix: serialize open-terminal launches during shell discovery
Await shell discovery on an ordered queue so a later path-only request
cannot create its tab first, including when App Lock drains.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:45:29 +00:00
Cursor Agentand陈大猫 69ef663844 feat: add --open-terminal-shell launch flag
Queue local-terminal launches as { path?, shell? } so a discovered shell
id or executable can open a tab on its own or with --open-terminal-path.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:08:38 +00:00
12 changed files with 564 additions and 33 deletions

No files matched your search

+19
View File
@@ -87,6 +87,7 @@ Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one c
- [Split Terminals](#split-terminals)
- [Supported Distros](#supported-distros)
- [Getting Started](#getting-started)
- [Open a local terminal from the command line](#open-a-local-terminal-from-the-command-line)
- [Build & Package](#build--package)
- [Tech Stack](#tech-stack)
- [Contributing](#contributing)
@@ -241,6 +242,24 @@ application and integration are complete.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
<a name="open-a-local-terminal-from-the-command-line"></a>
### Open a local terminal from the command line
`--open-terminal-path <dir>` opens a local terminal in that folder. `--open-terminal-shell <id-or-path>` selects the shell: a discovered id such as `pwsh`, `cmd`, or `wsl-ubuntu`, or a path to a shell executable. Either flag is enough. A shell alone opens a local tab in the default directory. A path alone uses the configured local shell. If Netcatty is already running, the tab opens in that window.
Windows needs an extra `--` so Electron forwards the flags:
```bat
Netcatty.exe -- --open-terminal-shell=pwsh --open-terminal-path="D:\\proj"
```
On macOS, start a new instance with `open`, or run the binary directly:
```bash
open -n -a Netcatty --args --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
/Applications/Netcatty.app/Contents/MacOS/Netcatty --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
```
### Scoop (Windows x64, community-maintained)
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
@@ -15,4 +15,14 @@ test("open-terminal requests wait behind App Lock and resume after unlock", () =
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
assert.match(handlerSource, /kind: 'open-terminal-path'/);
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
const processStart = source.indexOf("const _processOpenTerminalPath");
const processSource = source.slice(processStart, handlerStart);
assert.match(processSource, /handleCreateLocalTerminal\(undefined, \{ localStartDir \}\)/);
assert.match(processSource, /resolveShellSetting\(shellSetting, shells\)/);
assert.match(source, /const openTerminalQueueRef = useRef\(createOpenTerminalQueue\(\)\)/);
const enqueueAt = processSource.indexOf("openTerminalQueueRef.current.enqueue(() => {");
const createAt = processSource.indexOf("handleCreateLocalTerminal");
assert.ok(enqueueAt >= 0 && createAt > enqueueAt);
assert.match(processSource, /await ensureDiscoveredShells\(\)/);
});
+36 -5
View File
@@ -94,6 +94,7 @@ import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { createOpenTerminalQueue } from './openTerminalQueue';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
@@ -1557,7 +1558,7 @@ export function AppSideEffects() {
| { kind: 'ssh'; payload: DeepLinkPayload }
| { kind: 'telnet'; payload: DeepLinkPayload }
| { kind: 'jms'; payload: { url?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string; shell?: string } }
>>([]);
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
@@ -1721,14 +1722,44 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
// Shell discovery is part of this queue. A later path-only request, including
// one drained after App Lock, cannot create its tab ahead of an in-flight shell.
const openTerminalQueueRef = useRef(createOpenTerminalQueue());
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
const shellSetting = typeof payload?.shell === 'string' ? payload.shell.trim() : '';
void openTerminalQueueRef.current.enqueue(() => {
if (!shellSetting) {
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
return;
}
return (async () => {
const shells = await ensureDiscoveredShells();
const resolved = resolveShellSetting(shellSetting, shells);
if (!resolved?.command) {
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
return;
}
const matchedShell = shells.find((shell) => shell.id === shellSetting);
handleCreateLocalTerminal(
{
command: resolved.command,
args: resolved.args,
name: matchedShell?.name,
icon: matchedShell?.icon,
},
localStartDir.trim() ? { localStartDir } : undefined,
);
})();
});
});
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({
kind: 'open-terminal-path',
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOpenTerminalQueue } from './openTerminalQueue';
test('idle open-terminal work stays in call order on the caller turn', () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
queue.enqueue(() => { order.push('path-a'); });
queue.enqueue(() => { order.push('path-b'); });
assert.deepEqual(order, ['path-a', 'path-b']);
});
test('a path-only open waits for an in-flight shell discovery', async () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
let releaseDiscovery: () => void = () => {};
const discovery = new Promise<void>((resolve) => {
releaseDiscovery = resolve;
});
const shellRequest = queue.enqueue(async () => {
order.push('shell');
await discovery;
order.push('shell-tab');
});
const pathRequest = queue.enqueue(() => {
order.push('path-tab');
});
assert.deepEqual(order, ['shell']);
releaseDiscovery();
await shellRequest;
await pathRequest;
assert.deepEqual(order, ['shell', 'shell-tab', 'path-tab']);
});
test('a failed open-terminal request does not block the next one', async () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
const failed = queue.enqueue(async () => {
order.push('shell');
throw new Error('discovery failed');
});
const next = queue.enqueue(() => {
order.push('path-tab');
});
await assert.rejects(failed, /discovery failed/);
await next;
assert.deepEqual(order, ['shell', 'path-tab']);
});
+50
View File
@@ -0,0 +1,50 @@
type OpenTerminalTask = () => Promise<void> | void;
function isPromise(value: Promise<void> | void): value is Promise<void> {
return Boolean(value) && typeof (value as Promise<void>).then === 'function';
}
/**
* Ordered launcher queue. An idle queue runs the task in the caller turn so a
* path-only open still creates its tab synchronously. A task that awaits (shell
* discovery) holds the tail, and later requests cannot pass it.
*/
export function createOpenTerminalQueue() {
let tail: Promise<void> = Promise.resolve();
let depth = 0;
return {
enqueue(task: OpenTerminalTask): Promise<void> {
depth += 1;
let release: () => void;
const finished = new Promise<void>((resolve) => {
release = () => {
depth -= 1;
resolve();
};
});
const previous = tail;
tail = finished;
const run = (): Promise<void> => {
try {
const result = task();
if (isPromise(result)) {
return result.then(release, (error: unknown) => {
release();
throw error;
});
}
release();
return Promise.resolve();
} catch (error) {
release();
throw error;
}
};
if (depth === 1) return run();
return previous.then(run, run);
},
};
}
+12 -8
View File
@@ -97,9 +97,9 @@ const { PLUGIN_PROTOCOL_SCHEME } = require("./plugins/constants.cjs");
const { runPluginShutdown } = require("./plugins/shutdownCoordinator.cjs");
const {
OPEN_TERMINAL_PATH_CHANNEL,
collectOpenTerminalPathArgs,
hasOpenTerminalLaunchArgs,
resolveOpenTerminalPath,
resolveOpenTerminalPathsFromArgs,
resolveOpenTerminalRequestsFromArgs,
} = require("./openTerminalPath.cjs");
const {
applyExplorerContextMenuPreference,
@@ -709,7 +709,7 @@ const rawLaunchArgvForHandoff = [...process.argv];
// SecureCRT operands may contain PuTTY switch names; scrub them first.
redactSecureCrtCommandLinePasswords(process.argv);
redactPuttyCommandLinePasswords(process.argv);
const pendingOpenTerminalPaths = resolveOpenTerminalPathsFromArgs(process.argv);
const pendingOpenTerminalPaths = resolveOpenTerminalRequestsFromArgs(process.argv);
let flushingSshDeepLinks = false;
let flushingTelnetDeepLinks = false;
let flushingOpenTerminalPaths = false;
@@ -744,7 +744,7 @@ function queueTelnetDeepLink(rawUrl, { viaCommandLine = false, tabName } = {}) {
function queueOpenTerminalPath(rawPath, options = {}) {
const resolvedPath = resolveOpenTerminalPath(rawPath, options);
if (!resolvedPath) return;
pendingOpenTerminalPaths.push(resolvedPath);
pendingOpenTerminalPaths.push({ path: resolvedPath });
if (app.isReady?.()) {
void flushPendingOpenTerminalPaths();
}
@@ -1092,13 +1092,17 @@ async function flushPendingTelnetDeepLinks() {
}
async function deliverOpenTerminalPath(targetPath) {
const request = typeof targetPath === "string" ? { path: targetPath } : (targetPath || {});
const payload = {};
if (typeof request.path === "string" && request.path) payload.path = request.path;
if (typeof request.shell === "string" && request.shell) payload.shell = request.shell;
const win = await createAndShowMainWindow();
focusMainWindow();
const windowManager = getWindowManager();
const result = await windowManager.sendWhenRendererReady?.(
win,
OPEN_TERMINAL_PATH_CHANNEL,
{ path: targetPath },
payload,
{ timeoutMs: 0 },
);
if (result && result.success === false) {
@@ -1275,15 +1279,15 @@ if (!gotLock) {
});
return;
}
if (collectOpenTerminalPathArgs(secondInstanceArgv).length > 0) {
if (hasOpenTerminalLaunchArgs(secondInstanceArgv)) {
const baseDirectory = typeof workingDirectory === "string" ? workingDirectory : undefined;
const openTerminalPaths = resolveOpenTerminalPathsFromArgs(secondInstanceArgv, { baseDirectory });
const openTerminalPaths = resolveOpenTerminalRequestsFromArgs(secondInstanceArgv, { baseDirectory });
if (openTerminalPaths.length > 0) {
queueResolvedOpenTerminalPaths(openTerminalPaths);
} else {
// Still bring the app forward when Explorer launched us but the path
// failed validation — silent no-op feels like a broken menu item.
console.warn("[Main] Open-terminal-path args present but no valid path resolved:", secondInstanceArgv);
console.warn("[Main] Open-terminal args present but no valid request resolved:", secondInstanceArgv);
if (!focusMainWindow()) {
// Explicit foreground request (a second instance launch): if a
// still-pending hidden auto-launch cold start races ahead of the
+1 -1
View File
@@ -28,7 +28,7 @@ function createHarness() {
isDev: false,
readSshDeepLinkEnabledPreference: () => true,
readJmsDeepLinkEnabledPreference: () => false,
resolveOpenTerminalPathsFromArgs: () => [],
resolveOpenTerminalRequestsFromArgs: () => [],
resolveExplorerContextMenuEnabled: () => ({ enabled: false }),
applySshProtocolClientPreference: () => true,
writeSshDeepLinkEnabledPreference: () => true,
+77 -1
View File
@@ -4,11 +4,16 @@ const test = require("node:test");
const { readFileSync } = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const deepLink = require("./deepLink.cjs");
const {
collectSshDeepLinkQueueItems,
redactPuttyCommandLinePasswords,
redactSecureCrtCommandLinePasswords,
} = require("./deepLink.cjs");
} = deepLink;
const {
hasOpenTerminalLaunchArgs,
resolveOpenTerminalRequestsFromArgs,
} = require("./openTerminalPath.cjs");
test("second instance forwards its raw argv through the single-instance lock", () => {
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
@@ -81,6 +86,77 @@ test("failed protocol registration keeps command-line launch intents queued", ()
assert.match(callSource, /dropSchemePendingDeepLinks\(\)/);
});
function createOpenTerminalHarness() {
const source = readFileSync(path.join(__dirname, "main.cjs"), "utf8");
const start = source.indexOf('app.on("second-instance"');
const end = source.indexOf("// Application lifecycle", start);
assert.ok(start >= 0 && end > start);
let handle;
const queued = [];
let focusCount = 0;
const context = vm.createContext({
...deepLink,
app: { on: (_event, handler) => { handle = handler; } },
sshDeepLinkEnabled: false,
jmsDeepLinkEnabled: false,
queueSshDeepLink: (url) => queued.push({ kind: "ssh", url }),
queueTelnetDeepLink: (url) => queued.push({ kind: "telnet", url }),
queueJmsDeepLink: (url) => queued.push({ kind: "jms", url }),
hasOpenTerminalLaunchArgs,
resolveOpenTerminalRequestsFromArgs: (argv, options) => resolveOpenTerminalRequestsFromArgs(argv, {
...options,
fsModule: {
statSync: (target) => {
if (String(target).includes("missing")) throw new Error("missing");
return { isDirectory: () => true, isFile: () => false };
},
},
logWarn: () => {},
}),
queueResolvedOpenTerminalPaths: (resolved) => queued.push(Array.from(resolved)),
focusMainWindow: () => {
focusCount += 1;
return true;
},
createAndShowMainWindow: () => Promise.resolve(),
console: { warn: () => {}, error: () => {} },
});
vm.runInContext(source.slice(start, end), context);
return { handle, queued, focused: () => focusCount };
}
test("second-instance shell flag queues a local terminal without a path", () => {
const h = createOpenTerminalHarness();
const args = ["--open-terminal-shell", "pwsh"];
const argv = ["netcatty", ...args];
h.handle(null, argv, "/working-directory", { rawLaunchArgv: [...args] });
assert.deepEqual(h.queued, [[{ shell: "pwsh" }]]);
assert.equal(h.focused(), 0);
assert.equal(argv.length, 0);
});
test("second-instance path flag still queues a path-only terminal request", () => {
const h = createOpenTerminalHarness();
const args = ["--open-terminal-path", "proj"];
h.handle(null, ["netcatty", ...args], "/working-directory", { rawLaunchArgv: [...args] });
assert.deepEqual(h.queued, [[{ path: "/working-directory/proj" }]]);
});
test("second-instance shell and path flags queue one combined request", () => {
const h = createOpenTerminalHarness();
const args = ["--open-terminal-shell=pwsh", "--open-terminal-path", "proj"];
h.handle(null, ["netcatty", ...args], "/working-directory", { rawLaunchArgv: [...args] });
assert.deepEqual(h.queued, [[{ shell: "pwsh", path: "/working-directory/proj" }]]);
});
test("second-instance rejects an unsafe shell and still focuses the window", () => {
const h = createOpenTerminalHarness();
const args = ["--open-terminal-shell", "pwsh\0"];
h.handle(null, ["netcatty", ...args], "/working-directory", { rawLaunchArgv: [...args] });
assert.deepEqual(h.queued, []);
assert.equal(h.focused(), 1);
});
for (const secureCrt of [false, true]) {
for (const gotLock of [true, false]) {
test(`launch password snapshot is released after handoff (primary=${gotLock}, secureCrt=${secureCrt})`, () => {
@@ -3,7 +3,7 @@ const { readFileSync } = require("node:fs");
const test = require("node:test");
const vm = require("node:vm");
const deepLink = require("./deepLink.cjs");
const { collectOpenTerminalPathArgs } = require("./openTerminalPath.cjs");
const { hasOpenTerminalLaunchArgs } = require("./openTerminalPath.cjs");
function createHarness() {
const source = readFileSync(require.resolve("./main.cjs"), "utf8");
@@ -22,8 +22,8 @@ function createHarness() {
queueSshDeepLink: (url) => queued.push(url),
queueTelnetDeepLink: (url) => queued.push(url),
queueJmsDeepLink: (url) => queued.push(url),
collectOpenTerminalPathArgs,
resolveOpenTerminalPathsFromArgs: (argv, { baseDirectory }) => {
hasOpenTerminalLaunchArgs,
resolveOpenTerminalRequestsFromArgs: (argv, { baseDirectory }) => {
paths.push({ argv: Array.from(argv), baseDirectory });
return ["/resolved"];
},
+129 -14
View File
@@ -4,6 +4,10 @@ const path = require("node:path");
const OPEN_TERMINAL_PATH_CHANNEL = "netcatty:openTerminalPath";
const OPEN_TERMINAL_PATH_ARG = "--open-terminal-path";
const OPEN_TERMINAL_SHELL_ARG = "--open-terminal-shell";
// Discovered ids are short. The cap still leaves room for a nested executable
// path. Main only sanity-checks; the renderer resolves the id or path.
const MAX_OPEN_TERMINAL_SHELL_LENGTH = 4096;
function expandHomePath(targetPath, { osHomedir = os.homedir } = {}) {
if (!targetPath) return targetPath;
@@ -48,31 +52,120 @@ function normalizeOpenTerminalPathToken(rawPath) {
return value;
}
function collectOpenTerminalPathArgs(argv) {
function isOpenTerminalFlag(arg) {
return arg === OPEN_TERMINAL_PATH_ARG
|| arg === OPEN_TERMINAL_SHELL_ARG
|| (typeof arg === "string" && (
arg.startsWith(`${OPEN_TERMINAL_PATH_ARG}=`)
|| arg.startsWith(`${OPEN_TERMINAL_SHELL_ARG}=`)
));
}
function readOpenTerminalFlagValue(argv, index, flag) {
const arg = argv[index];
if (arg === flag) {
const next = argv[index + 1];
if (typeof next !== "string" || !next.trim() || isOpenTerminalFlag(next)) {
return { value: "", consumed: 0 };
}
return { value: next, consumed: 1 };
}
const prefix = `${flag}=`;
if (typeof arg === "string" && arg.startsWith(prefix)) {
return { value: arg.slice(prefix.length), consumed: 0 };
}
return null;
}
function sanitizeOpenTerminalShell(rawShell) {
if (typeof rawShell !== "string") return "";
let value = rawShell.trim();
if (
(value.startsWith('"') && value.endsWith('"') && value.length >= 2)
|| (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
) {
value = value.slice(1, -1).trim();
}
if (!value) return "";
if (value.length > MAX_OPEN_TERMINAL_SHELL_LENGTH) return "";
if (value.includes("\0")) return "";
return value;
}
/**
* Group adjacent path and shell flags into launch requests.
* A second path or shell starts a new request. Either field may be absent.
*/
function collectOpenTerminalRequests(argv) {
if (!Array.isArray(argv)) return [];
const paths = [];
const requests = [];
let current = null;
const flush = () => {
if (!current) return;
if (current.path || current.shell) requests.push(current);
current = null;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (typeof arg !== "string") continue;
if (arg === OPEN_TERMINAL_PATH_ARG) {
const next = argv[index + 1];
if (typeof next === "string" && next.trim()) {
paths.push(normalizeOpenTerminalPathToken(next));
index += 1;
const pathFlag = readOpenTerminalFlagValue(argv, index, OPEN_TERMINAL_PATH_ARG);
if (pathFlag) {
if (current?.path) flush();
const normalized = normalizeOpenTerminalPathToken(pathFlag.value);
if (normalized) {
if (!current) current = {};
current.path = normalized;
}
index += pathFlag.consumed;
continue;
}
const prefix = `${OPEN_TERMINAL_PATH_ARG}=`;
if (arg.startsWith(prefix)) {
const value = arg.slice(prefix.length);
if (value.trim()) paths.push(normalizeOpenTerminalPathToken(value));
const shellFlag = readOpenTerminalFlagValue(argv, index, OPEN_TERMINAL_SHELL_ARG);
if (shellFlag) {
if (current?.shell) flush();
const shell = sanitizeOpenTerminalShell(shellFlag.value);
if (shell) {
if (!current) current = {};
current.shell = shell;
}
index += shellFlag.consumed;
continue;
}
}
return paths.filter(Boolean);
flush();
return requests;
}
function collectOpenTerminalPathArgs(argv) {
return collectOpenTerminalRequests(argv)
.map((request) => request.path)
.filter(Boolean);
}
function hasOpenTerminalLaunchArgs(argv) {
if (!Array.isArray(argv)) return false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (typeof arg !== "string") continue;
if (arg === OPEN_TERMINAL_PATH_ARG || arg === OPEN_TERMINAL_SHELL_ARG) {
const next = argv[index + 1];
if (typeof next === "string" && next.trim() && !isOpenTerminalFlag(next)) return true;
continue;
}
const pathPrefix = `${OPEN_TERMINAL_PATH_ARG}=`;
const shellPrefix = `${OPEN_TERMINAL_SHELL_ARG}=`;
if (arg.startsWith(pathPrefix) || arg.startsWith(shellPrefix)) {
const value = arg.startsWith(pathPrefix)
? arg.slice(pathPrefix.length)
: arg.slice(shellPrefix.length);
if (value.trim()) return true;
}
}
return false;
}
function resolveOpenTerminalPath(rawPath, {
@@ -99,18 +192,40 @@ function resolveOpenTerminalPath(rawPath, {
}
}
function resolveOpenTerminalRequestsFromArgs(argv, options = {}) {
const requests = [];
for (const raw of collectOpenTerminalRequests(argv)) {
if (raw.path) {
const resolvedPath = resolveOpenTerminalPath(raw.path, options);
// A requested directory that does not resolve must not fall open in the
// default directory, even when a shell was paired with it.
if (!resolvedPath) continue;
requests.push(raw.shell ? { path: resolvedPath, shell: raw.shell } : { path: resolvedPath });
continue;
}
if (raw.shell) requests.push({ shell: raw.shell });
}
return requests;
}
function resolveOpenTerminalPathsFromArgs(argv, options = {}) {
return collectOpenTerminalPathArgs(argv)
.map((rawPath) => resolveOpenTerminalPath(rawPath, options))
return resolveOpenTerminalRequestsFromArgs(argv, options)
.map((request) => request.path)
.filter(Boolean);
}
module.exports = {
MAX_OPEN_TERMINAL_SHELL_LENGTH,
OPEN_TERMINAL_PATH_ARG,
OPEN_TERMINAL_PATH_CHANNEL,
OPEN_TERMINAL_SHELL_ARG,
collectOpenTerminalPathArgs,
collectOpenTerminalRequests,
expandHomePath,
hasOpenTerminalLaunchArgs,
normalizeOpenTerminalPathToken,
resolveOpenTerminalPath,
resolveOpenTerminalPathsFromArgs,
resolveOpenTerminalRequestsFromArgs,
sanitizeOpenTerminalShell,
};
+172
View File
@@ -2,11 +2,16 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const {
MAX_OPEN_TERMINAL_SHELL_LENGTH,
collectOpenTerminalPathArgs,
collectOpenTerminalRequests,
expandHomePath,
hasOpenTerminalLaunchArgs,
normalizeOpenTerminalPathToken,
resolveOpenTerminalPath,
resolveOpenTerminalPathsFromArgs,
resolveOpenTerminalRequestsFromArgs,
sanitizeOpenTerminalShell,
} = require("./openTerminalPath.cjs");
test("collectOpenTerminalPathArgs extracts explicit open terminal paths", () => {
@@ -126,6 +131,173 @@ test("resolveOpenTerminalPath uses parent directory for files", () => {
);
});
function directoryFs() {
return {
statSync: () => ({
isDirectory: () => true,
isFile: () => false,
}),
};
}
test("collectOpenTerminalRequests pairs a shell with a path in either order", () => {
assert.deepEqual(
collectOpenTerminalRequests([
"Netcatty.exe",
"--",
"--open-terminal-shell=pwsh",
"--open-terminal-path",
String.raw`D:\proj`,
]),
[{ shell: "pwsh", path: String.raw`D:\proj` }],
);
assert.deepEqual(
collectOpenTerminalRequests([
"--open-terminal-path=/tmp/demo",
"--open-terminal-shell",
"/usr/local/bin/fish",
]),
[{ path: "/tmp/demo", shell: "/usr/local/bin/fish" }],
);
});
test("collectOpenTerminalRequests opens on a shell flag alone and keeps path-only requests", () => {
assert.deepEqual(
collectOpenTerminalRequests(["--open-terminal-shell", "wsl-ubuntu"]),
[{ shell: "wsl-ubuntu" }],
);
assert.deepEqual(
collectOpenTerminalRequests(["--open-terminal-path", "/tmp/demo"]),
[{ path: "/tmp/demo" }],
);
});
test("collectOpenTerminalRequests starts a new request when a flag repeats", () => {
assert.deepEqual(
collectOpenTerminalRequests([
"--open-terminal-shell",
"pwsh",
"--open-terminal-path",
"/tmp/a",
"--open-terminal-shell",
"cmd",
"--open-terminal-path",
"/tmp/b",
]),
[
{ shell: "pwsh", path: "/tmp/a" },
{ shell: "cmd", path: "/tmp/b" },
],
);
});
test("collectOpenTerminalRequests does not swallow a sibling flag as a value", () => {
assert.deepEqual(
collectOpenTerminalRequests([
"--open-terminal-shell",
"--open-terminal-path=/tmp/demo",
]),
[{ path: "/tmp/demo" }],
);
});
test("sanitizeOpenTerminalShell rejects empty, NUL, and over-long values", () => {
assert.equal(sanitizeOpenTerminalShell(" pwsh "), "pwsh");
assert.equal(sanitizeOpenTerminalShell('"pwsh"'), "pwsh");
assert.equal(sanitizeOpenTerminalShell(""), "");
assert.equal(sanitizeOpenTerminalShell(" "), "");
assert.equal(sanitizeOpenTerminalShell("pwsh\0"), "");
assert.equal(sanitizeOpenTerminalShell(`\0pwsh`), "");
assert.equal(sanitizeOpenTerminalShell("a".repeat(MAX_OPEN_TERMINAL_SHELL_LENGTH)), "a".repeat(MAX_OPEN_TERMINAL_SHELL_LENGTH));
assert.equal(sanitizeOpenTerminalShell("a".repeat(MAX_OPEN_TERMINAL_SHELL_LENGTH + 1)), "");
});
test("resolveOpenTerminalRequestsFromArgs keeps a shell-only launch without touching the filesystem", () => {
assert.deepEqual(
resolveOpenTerminalRequestsFromArgs(["--open-terminal-shell", "cmd"], {
fsModule: {
statSync: () => {
throw new Error("shell-only must not stat");
},
},
logWarn: () => {},
}),
[{ shell: "cmd" }],
);
});
test("resolveOpenTerminalRequestsFromArgs returns a path-only request unchanged", () => {
assert.deepEqual(
resolveOpenTerminalRequestsFromArgs([
"--open-terminal-path",
"project",
], {
baseDirectory: "/Users/alice",
fsModule: directoryFs(),
logWarn: () => {},
}),
[{ path: "/Users/alice/project" }],
);
});
test("resolveOpenTerminalRequestsFromArgs pairs a resolved directory with the shell", () => {
assert.deepEqual(
resolveOpenTerminalRequestsFromArgs([
"--open-terminal-shell=pwsh",
"--open-terminal-path",
"project",
], {
baseDirectory: "/Users/alice",
fsModule: directoryFs(),
logWarn: () => {},
}),
[{ shell: "pwsh", path: "/Users/alice/project" }],
);
});
test("resolveOpenTerminalRequestsFromArgs drops a paired shell when the path is invalid", () => {
assert.deepEqual(
resolveOpenTerminalRequestsFromArgs([
"--open-terminal-shell",
"pwsh",
"--open-terminal-path",
"/tmp/missing",
], {
fsModule: {
statSync: () => {
throw new Error("missing");
},
},
logWarn: () => {},
}),
[],
);
});
test("resolveOpenTerminalRequestsFromArgs keeps a valid path when the shell fails the sanity check", () => {
assert.deepEqual(
resolveOpenTerminalRequestsFromArgs([
"--open-terminal-path",
"/tmp/project",
"--open-terminal-shell",
"pwsh\0",
], {
fsModule: directoryFs(),
logWarn: () => {},
}),
[{ path: "/tmp/project" }],
);
});
test("hasOpenTerminalLaunchArgs is true for either flag and false for an empty value", () => {
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-shell", "pwsh"]), true);
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-path=/tmp/demo"]), true);
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-shell", "bad\0shell"]), true);
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-shell="]), false);
assert.equal(hasOpenTerminalLaunchArgs(["--open-terminal-path"]), false);
assert.equal(hasOpenTerminalLaunchArgs(["--other"]), false);
});
test("resolveOpenTerminalPath rejects missing paths", () => {
const warnings = [];
const fsModule = {
+1 -1
View File
@@ -33,7 +33,7 @@ declare global {
onUpdateNeedsSave?(cb: () => void): () => void;
onSshDeepLink?(cb: (payload: { url?: string; tabName?: string }) => void): () => void;
onTelnetDeepLink?(cb: (payload: { url?: string; tabName?: string }) => void): () => void;
onOpenTerminalPath?(cb: (payload: { path?: string }) => void): () => void;
onOpenTerminalPath?(cb: (payload: { path?: string; shell?: string }) => void): () => void;
/** Fired once after cold-start deep-link / open-terminal queues have been drained. */
onColdStartIntentsSettled?(cb: () => void): () => void;
setSshDeepLinkEnabled?(enabled: boolean): Promise<boolean | { success: boolean; enabled: boolean }>;