diff --git a/sdk/typescript/src/bulk-scan-discovery.ts b/sdk/typescript/src/bulk-scan-discovery.ts index cfad210a..bc72fbf0 100644 --- a/sdk/typescript/src/bulk-scan-discovery.ts +++ b/sdk/typescript/src/bulk-scan-discovery.ts @@ -8,6 +8,7 @@ import { promisify } from "node:util"; import { confirm, input, search } from "@inquirer/prompts"; import { Octokit } from "@octokit/core"; import Papa from "papaparse"; +import { expandHome } from "./runtime.js"; import { resolveTrustedExecutable } from "./trusted-executable.js"; const execFile = promisify(execFileCallback); @@ -160,9 +161,11 @@ export async function runBulkScanWizard( const outputDir = resolve( dependencies.currentDirectory(), - await prompt.input( - "Where should scan results be saved?", - "./security-scans", + expandHome( + await prompt.input( + "Where should scan results be saved?", + "./security-scans", + ), ), ); const inputPath = join(outputDir, "repositories.csv"); diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 1abe8302..d480593d 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -180,6 +180,10 @@ function optionValue(flag: string) { return z.string().min(1, `${flag} must not be empty.`); } +function resolveCliPath(directory: string, value: string): string { + return resolve(directory, expandHome(value)); +} + function effortOption() { return z .enum(MODEL_REASONING_EFFORTS, { @@ -734,14 +738,14 @@ export async function main( const repository = options.scanRoot !== undefined && args.repository === undefined ? undefined - : resolve(directory, args.repository ?? directory); + : resolveCliPath(directory, args.repository ?? directory); return presentHistory( await history([ "list-scans", ...(repository === undefined ? [] : ["--repository", repository]), ...(options.scanRoot === undefined ? [] - : ["--scan-root", resolve(directory, options.scanRoot)]), + : ["--scan-root", resolveCliPath(directory, options.scanRoot)]), ]), "list", format, @@ -750,7 +754,7 @@ export async function main( scanRoot: options.scanRoot === undefined ? undefined - : resolve(directory, options.scanRoot), + : resolveCliPath(directory, options.scanRoot), }, ); }, @@ -1111,7 +1115,10 @@ export async function main( "git", [ "-C", - resolve(dependencies.currentDirectory(), args.repository ?? "."), + resolveCliPath( + dependencies.currentDirectory(), + args.repository ?? ".", + ), "rev-parse", "--path-format=absolute", "--git-path", @@ -1284,8 +1291,8 @@ export async function main( "--output-dir is required with a repository CSV.", ); } - inputPath = resolve(currentDirectory, args.input); - outputDir = resolve(currentDirectory, options.outputDir); + inputPath = resolveCliPath(currentDirectory, args.input); + outputDir = resolveCliPath(currentDirectory, options.outputDir); } const result = await runMultiscan({ inputPath, @@ -1369,12 +1376,12 @@ export async function main( const currentDirectory = dependencies.currentDirectory(); exitCode = await runExport( { - scanDir: resolve(currentDirectory, args.scanDir), + scanDir: resolveCliPath(currentDirectory, args.scanDir), format: options.exportFormat, output: options.output === "-" ? "-" - : resolve( + : resolveCliPath( currentDirectory, options.output ?? EXPORT_DEFAULT_OUTPUTS[options.exportFormat], @@ -1382,7 +1389,7 @@ export async function main( sourceRoot: options.sourceRoot === undefined ? undefined - : resolve(currentDirectory, options.sourceRoot), + : resolveCliPath(currentDirectory, options.sourceRoot), pythonPath: options.python, }, output, diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 93b98c5e..ec5d89ca 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -2006,9 +2006,11 @@ async function sameFile(left: string, right: string): Promise { } export function expandHome(value: string): string { - if (value === "~") return homedir(); + const home = + process.env["HOME"]?.trim() || process.env["USERPROFILE"]?.trim() || homedir(); + if (value === "~") return home; if (value.startsWith("~/") || value.startsWith("~\\")) { - return join(homedir(), value.slice(2)); + return join(home, value.slice(2)); } return value; } diff --git a/sdk/typescript/src/targets.ts b/sdk/typescript/src/targets.ts index 0cd58206..618753fd 100644 --- a/sdk/typescript/src/targets.ts +++ b/sdk/typescript/src/targets.ts @@ -438,11 +438,13 @@ function abortReason(signal: AbortSignal): unknown { } function expandHome(value: string): string { + const home = + process.env["HOME"]?.trim() || process.env["USERPROFILE"]?.trim() || homedir(); if (value === "~") { - return homedir(); + return home; } if (value.startsWith("~/") || value.startsWith("~\\")) { - return resolve(homedir(), value.slice(2).replace(/^[/\\]+/, "")); + return resolve(home, value.slice(2).replace(/^[/\\]+/, "")); } return value; } diff --git a/sdk/typescript/tests-ts/bulk-scan-discovery.test.ts b/sdk/typescript/tests-ts/bulk-scan-discovery.test.ts index a8337649..90736337 100644 --- a/sdk/typescript/tests-ts/bulk-scan-discovery.test.ts +++ b/sdk/typescript/tests-ts/bulk-scan-discovery.test.ts @@ -282,6 +282,31 @@ describe("bulk scan repository discovery", () => { expect(csv).not.toContain("unrelated"); }); + test("expands a home-relative output directory from the wizard prompt", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const previousHome = process.env.HOME; + const previousProfile = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + try { + const { dependencies, prompt } = discoveryDependencies(root); + prompt.confirms = [true]; + prompt.inputs = ["~/custom-results"]; + + const result = await runBulkScanWizard(dependencies); + + expect(result?.outputDir).toBe(join(home, "custom-results")); + expect(await lstat(join(root, "~")).catch(() => null)).toBeNull(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousProfile; + } + }); + test("includes public repositories and excludes archived, forked, and empty repositories", async () => { const root = await temporaryDirectory(); const { dependencies, prompt } = discoveryDependencies(root, { diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 84b7d7c2..6ee2d830 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -701,6 +701,49 @@ describe("CLI", () => { } }); + test("expands ~ in bulk-scan path arguments", async () => { + const root = await mkdtemp(join(tmpdir(), "codex-security-cli-tilde-")); + const home = join(root, "home"); + const work = join(root, "work"); + const previousHome = process.env.HOME; + const previousProfile = process.env.USERPROFILE; + try { + await mkdir(home); + await mkdir(work); + await multiscanInventory(home); + process.env.HOME = home; + process.env.USERPROFILE = home; + const stdout = capture(); + const stderr = capture(); + expect( + await main( + [ + "bulk-scan", + "~/repositories.csv", + "--output-dir", + "~/security-scans", + "--json", + ], + stdout.stream, + stderr.stream, + dependencies({ + currentDirectory: work, + }), + ), + ).toBe(0); + expect(JSON.parse(stdout.text())).toMatchObject({ + resultsPath: join(home, "security-scans", "results.jsonl"), + }); + expect(await stat(join(work, "~")).catch(() => null)).toBeNull(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousProfile; + await rm(root, { recursive: true, force: true }); + } + }); + test("preserves the bulk-scan failure summary and redacts progress errors", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-multiscan-")); try {