Fix desktop CLI passthrough tty handling (#791)
* Fix desktop CLI passthrough tty handling * Fix desktop daemon manager test drift * Update daemon stop test after main merge * test(cli): isolate npx cache per CLI test
This commit is contained in:
@@ -1,26 +1,6 @@
|
||||
import { createCli } from "./cli.js";
|
||||
import { classifyInvocation } from "./classify.js";
|
||||
import { openDesktopWithProject } from "./commands/open.js";
|
||||
import { runCli } from "./run.js";
|
||||
|
||||
const program = createCli();
|
||||
const knownCommands = new Set(program.commands.map((command) => command.name()));
|
||||
|
||||
const invocation = classifyInvocation({
|
||||
argv: process.argv.slice(2),
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
const exitCode = await runCli(process.argv.slice(2), {
|
||||
nodeArgv: [process.argv[0] ?? "node", process.argv[1] ?? "paseo"],
|
||||
});
|
||||
|
||||
switch (invocation.kind) {
|
||||
case "cli": {
|
||||
const argv = [...process.argv.slice(0, 2), ...invocation.argv];
|
||||
if (invocation.argv.length === 0) {
|
||||
argv.push("onboard");
|
||||
}
|
||||
program.parse(argv, { from: "node" });
|
||||
break;
|
||||
}
|
||||
case "open-project":
|
||||
await openDesktopWithProject(invocation.resolvedPath);
|
||||
break;
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCliParseArgv } from "./run";
|
||||
|
||||
describe("runCli", () => {
|
||||
it("defaults an empty CLI invocation to onboard", () => {
|
||||
expect(
|
||||
createCliParseArgv({
|
||||
argv: [],
|
||||
cwd: process.cwd(),
|
||||
nodeArgv: ["node", "paseo"],
|
||||
}),
|
||||
).toEqual(["node", "paseo", "onboard"]);
|
||||
});
|
||||
|
||||
it("preserves known CLI command argv", () => {
|
||||
expect(
|
||||
createCliParseArgv({
|
||||
argv: ["daemon", "set-password"],
|
||||
cwd: process.cwd(),
|
||||
nodeArgv: ["node", "paseo"],
|
||||
}),
|
||||
).toEqual(["node", "paseo", "daemon", "set-password"]);
|
||||
});
|
||||
|
||||
it("classifies existing unknown directories as open-project invocations", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-"));
|
||||
const project = path.join(root, "project");
|
||||
mkdirSync(project);
|
||||
|
||||
try {
|
||||
expect(
|
||||
createCliParseArgv({
|
||||
argv: ["project"],
|
||||
cwd: root,
|
||||
nodeArgv: ["node", "paseo"],
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: project,
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createCli } from "./cli.js";
|
||||
import { classifyInvocation } from "./classify.js";
|
||||
import { openDesktopWithProject } from "./commands/open.js";
|
||||
|
||||
export interface RunCliOptions {
|
||||
cwd?: string;
|
||||
nodeArgv?: [string, string];
|
||||
}
|
||||
|
||||
export function createCliParseArgv(input: {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
nodeArgv?: [string, string];
|
||||
}): string[] | { kind: "open-project"; resolvedPath: string } {
|
||||
const program = createCli();
|
||||
const knownCommands = new Set(program.commands.map((command) => command.name()));
|
||||
const invocation = classifyInvocation({
|
||||
argv: input.argv,
|
||||
knownCommands,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
|
||||
if (invocation.kind === "open-project") {
|
||||
return invocation;
|
||||
}
|
||||
|
||||
const nodeArgv = input.nodeArgv ?? ["paseo", "paseo"];
|
||||
const cliArgv = invocation.argv.length === 0 ? ["onboard"] : invocation.argv;
|
||||
return [...nodeArgv, ...cliArgv];
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> {
|
||||
const parseArgv = createCliParseArgv({
|
||||
argv,
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
nodeArgv: options.nodeArgv,
|
||||
});
|
||||
|
||||
if (!Array.isArray(parseArgv)) {
|
||||
await openDesktopWithProject(parseArgv.resolvedPath);
|
||||
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
||||
}
|
||||
|
||||
const program = createCli();
|
||||
await program.parseAsync(parseArgv, { from: "node" });
|
||||
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { $ } from "zx";
|
||||
import { readdir, writeFile } from "fs/promises";
|
||||
import { mkdtemp, readdir, rm, writeFile } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join, dirname, delimiter } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
@@ -192,55 +193,61 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
|
||||
const testPath = join(__dirname, testFile);
|
||||
const testName = testFile.replace(/\.test\.ts$/, "");
|
||||
const startedAt = Date.now();
|
||||
const npmCache = await mkdtemp(join(tmpdir(), "paseo-cli-test-npm-cache-"));
|
||||
|
||||
return new Promise<TestOutcome>((resolve) => {
|
||||
const proc = spawn("npx", ["tsx", testPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
||||
PASEO_DICTATION_ENABLED: testEnvDefaults.PASEO_DICTATION_ENABLED,
|
||||
PASEO_VOICE_MODE_ENABLED: testEnvDefaults.PASEO_VOICE_MODE_ENABLED,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
try {
|
||||
return await new Promise<TestOutcome>((resolve) => {
|
||||
const proc = spawn("npx", ["tsx", testPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
|
||||
npm_config_cache: npmCache,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
||||
PASEO_DICTATION_ENABLED: testEnvDefaults.PASEO_DICTATION_ENABLED,
|
||||
PASEO_VOICE_MODE_ENABLED: testEnvDefaults.PASEO_VOICE_MODE_ENABLED,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
flushTestBlock(testName, durationMs, false, stdout, stderr || message);
|
||||
resolve({
|
||||
status: "failed",
|
||||
durationMs,
|
||||
failure: { test: testName, error: message },
|
||||
proc.on("error", (err) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
flushTestBlock(testName, durationMs, false, stdout, stderr || message);
|
||||
resolve({
|
||||
status: "failed",
|
||||
durationMs,
|
||||
failure: { test: testName, error: message },
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const exitCode = code ?? 1;
|
||||
if (exitCode === 0) {
|
||||
flushTestBlock(testName, durationMs, true, stdout, stderr);
|
||||
resolve({ status: "passed", durationMs });
|
||||
return;
|
||||
}
|
||||
flushTestBlock(testName, durationMs, false, stdout, stderr);
|
||||
resolve({
|
||||
status: "failed",
|
||||
durationMs,
|
||||
failure: { test: testName, error: stderr || `Exit code: ${exitCode}` },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const exitCode = code ?? 1;
|
||||
if (exitCode === 0) {
|
||||
flushTestBlock(testName, durationMs, true, stdout, stderr);
|
||||
resolve({ status: "passed", durationMs });
|
||||
return;
|
||||
}
|
||||
flushTestBlock(testName, durationMs, false, stdout, stderr);
|
||||
resolve({
|
||||
status: "failed",
|
||||
durationMs,
|
||||
failure: { test: testName, error: stderr || `Exit code: ${exitCode}` },
|
||||
});
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rm(npmCache, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function flushTestBlock(
|
||||
|
||||
Reference in New Issue
Block a user