diff --git a/cli/bin/javachat.js b/cli/bin/javachat.js index 25bdd908..dff86bce 100755 --- a/cli/bin/javachat.js +++ b/cli/bin/javachat.js @@ -13,7 +13,7 @@ import { spawn, spawnSync } from "node:child_process"; import { randomUUID, randomBytes } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; import { hostname, homedir, platform } from "node:os"; -import { mkdir, readFile, writeFile, chmod, rm } from "node:fs/promises"; +import { mkdir, readFile, readdir, writeFile, chmod, rm } from "node:fs/promises"; import { basename, dirname, join, resolve, sep } from "node:path"; import { stdout, stderr, argv, exit, env } from "node:process"; import { stripVTControlCharacters } from "node:util"; @@ -379,20 +379,25 @@ async function resolveNpmInstallTarget() { throw new Error('"javachat update" cannot update an ephemeral npx installation.'); } const projectManifest = await readProjectManifest(projectRoot); - const declaresJavaChat = [ - projectManifest.dependencies, - projectManifest.devDependencies, - projectManifest.optionalDependencies, - ].some((dependencyGroup) => Object.hasOwn(dependencyGroup ?? {}, CLI_PACKAGE)); - if (!declaresJavaChat) { - throw new Error( - `The project at ${projectRoot} does not declare ${CLI_PACKAGE}; npm will not be allowed to modify it.`, - ); + if (manifestDeclaresJavaChat(projectManifest)) { + return { + npmArguments: ["install", `${CLI_PACKAGE}@latest`], + workingDirectory: projectRoot, + }; } - return { - npmArguments: ["install", `${CLI_PACKAGE}@latest`], - workingDirectory: projectRoot, - }; + const declaringWorkspaceMember = await findDeclaringWorkspaceMember( + projectRoot, + projectManifest, + ); + if (declaringWorkspaceMember) { + return { + npmArguments: ["install", `${CLI_PACKAGE}@latest`], + workingDirectory: declaringWorkspaceMember, + }; + } + throw new Error( + `The project at ${projectRoot} does not declare ${CLI_PACKAGE}; npm will not be allowed to modify it.`, + ); } const prefixResult = spawnSync("npm", ["prefix", "--global"], { encoding: "utf8" }); @@ -427,6 +432,118 @@ async function readProjectManifest(projectRoot) { } } +/** Whether a manifest lists CLI_PACKAGE under any dependency group. */ +function manifestDeclaresJavaChat(projectManifest) { + return [ + projectManifest.dependencies, + projectManifest.devDependencies, + projectManifest.optionalDependencies, + ].some((dependencyGroup) => Object.hasOwn(dependencyGroup ?? {}, CLI_PACKAGE)); +} + +/** + * Finds the directory of a workspace member that declares CLI_PACKAGE. + * + * Under npm workspaces the invoked binary and its package are hoisted to the + * workspace root, so the manifest at projectRoot may not itself declare the + * CLI. This consults the root's `workspaces` field, expands the patterns the + * way npm does (literal paths plus ``*``/``**`` globs), and returns the first + * member directory whose manifest declares the package. Any enumeration or + * manifest-read failure falls through to ``null`` so the caller can refuse + * closed with the existing message. + */ +async function findDeclaringWorkspaceMember(projectRoot, rootManifest) { + const workspacePatterns = extractWorkspacePatterns(rootManifest); + if (!workspacePatterns) return null; + try { + for (const workspacePattern of workspacePatterns) { + for (const memberDirectory of await expandWorkspaceGlob(projectRoot, workspacePattern)) { + let memberManifest; + try { + memberManifest = await readProjectManifest(memberDirectory); + } catch { + continue; + } + if (manifestDeclaresJavaChat(memberManifest)) return memberDirectory; + } + } + } catch { + return null; + } + return null; +} + +/** Normalizes the `workspaces` field into an array of pattern strings, or null. */ +function extractWorkspacePatterns(rootManifest) { + const workspaces = rootManifest.workspaces; + if (typeof workspaces === "string") return [workspaces]; + if (Array.isArray(workspaces)) { + return workspaces.filter((entry) => typeof entry === "string"); + } + if (workspaces && typeof workspaces === "object" && Array.isArray(workspaces.packages)) { + return workspaces.packages.filter((entry) => typeof entry === "string"); + } + return null; +} + +/** Expands a single workspace glob relative to the root into member directories. */ +async function expandWorkspaceGlob(projectRoot, pattern) { + const cleanPattern = pattern.replace(/^\.\//, "").replace(/\/+$/, ""); + if (!cleanPattern) return []; + const memberDirectories = []; + await matchWorkspaceSegments(projectRoot, cleanPattern.split("/"), 0, memberDirectories); + return memberDirectories; +} + +async function matchWorkspaceSegments(currentDirectory, segments, index, memberDirectories) { + if (index >= segments.length) { + if (existsSync(join(currentDirectory, "package.json"))) memberDirectories.push(currentDirectory); + return; + } + const segment = segments[index]; + if (segment === "**") { + await matchWorkspaceSegments(currentDirectory, segments, index + 1, memberDirectories); + let directoryEntries; + try { + directoryEntries = await readdir(currentDirectory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of directoryEntries) { + if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { + await matchWorkspaceSegments(join(currentDirectory, entry.name), segments, index, memberDirectories); + } + } + return; + } + if (segment.includes("*")) { + const entryPattern = workspaceSegmentToRegex(segment); + let directoryEntries; + try { + directoryEntries = await readdir(currentDirectory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of directoryEntries) { + if (entry.isDirectory() && entryPattern.test(entry.name)) { + await matchWorkspaceSegments(join(currentDirectory, entry.name), segments, index + 1, memberDirectories); + } + } + return; + } + await matchWorkspaceSegments(join(currentDirectory, segment), segments, index + 1, memberDirectories); +} + +function workspaceSegmentToRegex(segment) { + let regex = "^"; + for (const character of segment) { + if (character === "*") regex += "[^/]*"; + else if ("\\^$.+?()[]{}|".includes(character)) regex += `\\${character}`; + else regex += character; + } + return new RegExp(`${regex}$`); +} + /** * Lists the document groups ingested in the deployment's knowledge base. * diff --git a/cli/test/javachat.test.js b/cli/test/javachat.test.js index 286d3daa..dcdcd8d5 100644 --- a/cli/test/javachat.test.js +++ b/cli/test/javachat.test.js @@ -273,6 +273,318 @@ test("propagates an npm update failure", async (testContext) => { assert.doesNotMatch(cliExecution.standardOutput, /update complete/); }); +test("updates a workspace member that declares a local JavaChat dependency", async (testContext) => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "javachat-cli-workspace-test-")); + testContext.after(() => rm(workspaceRoot, { recursive: true, force: true })); + await writeFile( + join(workspaceRoot, "package.json"), + JSON.stringify({ + name: "monorepo", + version: "1.0.0", + private: true, + workspaces: ["packages/*"], + }), + ); + await mkdir(join(workspaceRoot, "packages", "app"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "app", "package.json"), + JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { "@wcallahan/javachat-cli": "0.0.2" }, + }), + ); + await mkdir(join(workspaceRoot, "packages", "tools"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "tools", "package.json"), + JSON.stringify({ name: "tools", version: "1.0.0" }), + ); + const packageRoot = join(workspaceRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(workspaceRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(workspaceRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.match(cliExecution.standardOutput, /JavaChat CLI update complete/); + assert.equal(cliExecution.standardError, "Updating @wcallahan/javachat-cli with npm...\n"); + assert.deepEqual(await readNpmInvocations(invocationLog), [ + { + argumentsList: ["install", "@wcallahan/javachat-cli@latest"], + workingDirectory: await realpath(join(workspaceRoot, "packages", "app")), + }, + ]); +}); + +test("updates a workspace member that declares JavaChat as a dev dependency", async (testContext) => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "javachat-cli-workspace-dev-test-")); + testContext.after(() => rm(workspaceRoot, { recursive: true, force: true })); + await writeFile( + join(workspaceRoot, "package.json"), + JSON.stringify({ + name: "monorepo", + version: "1.0.0", + private: true, + workspaces: ["packages/*"], + }), + ); + await mkdir(join(workspaceRoot, "packages", "app"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "app", "package.json"), + JSON.stringify({ + name: "app", + version: "1.0.0", + devDependencies: { "@wcallahan/javachat-cli": "0.0.2" }, + }), + ); + const packageRoot = join(workspaceRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(workspaceRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(workspaceRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.deepEqual(await readNpmInvocations(invocationLog), [ + { + argumentsList: ["install", "@wcallahan/javachat-cli@latest"], + workingDirectory: await realpath(join(workspaceRoot, "packages", "app")), + }, + ]); +}); + +test("updates a workspace member addressed by a literal workspace path", async (testContext) => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "javachat-cli-workspace-literal-test-")); + testContext.after(() => rm(workspaceRoot, { recursive: true, force: true })); + await writeFile( + join(workspaceRoot, "package.json"), + JSON.stringify({ + name: "monorepo", + version: "1.0.0", + private: true, + workspaces: ["packages/app"], + }), + ); + await mkdir(join(workspaceRoot, "packages", "app"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "app", "package.json"), + JSON.stringify({ + name: "app", + version: "1.0.0", + optionalDependencies: { "@wcallahan/javachat-cli": "0.0.2" }, + }), + ); + const packageRoot = join(workspaceRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(workspaceRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(workspaceRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.deepEqual(await readNpmInvocations(invocationLog), [ + { + argumentsList: ["install", "@wcallahan/javachat-cli@latest"], + workingDirectory: await realpath(join(workspaceRoot, "packages", "app")), + }, + ]); +}); + +test("updates a nested workspace member matched by a recursive glob", async (testContext) => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "javachat-cli-workspace-nested-test-")); + testContext.after(() => rm(workspaceRoot, { recursive: true, force: true })); + await writeFile( + join(workspaceRoot, "package.json"), + JSON.stringify({ + name: "monorepo", + version: "1.0.0", + private: true, + workspaces: ["packages/**"], + }), + ); + await mkdir(join(workspaceRoot, "packages", "group", "app"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "group", "app", "package.json"), + JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { "@wcallahan/javachat-cli": "0.0.2" }, + }), + ); + await mkdir(join(workspaceRoot, "packages", "group", "tools"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "group", "tools", "package.json"), + JSON.stringify({ name: "tools", version: "1.0.0" }), + ); + const packageRoot = join(workspaceRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(workspaceRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(workspaceRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.deepEqual(await readNpmInvocations(invocationLog), [ + { + argumentsList: ["install", "@wcallahan/javachat-cli@latest"], + workingDirectory: await realpath(join(workspaceRoot, "packages", "group", "app")), + }, + ]); +}); + +test("updates one of several workspace members that declare JavaChat", async (testContext) => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "javachat-cli-workspace-multi-test-")); + testContext.after(() => rm(workspaceRoot, { recursive: true, force: true })); + await writeFile( + join(workspaceRoot, "package.json"), + JSON.stringify({ + name: "monorepo", + version: "1.0.0", + private: true, + workspaces: ["packages/*"], + }), + ); + await mkdir(join(workspaceRoot, "packages", "app"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "app", "package.json"), + JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { "@wcallahan/javachat-cli": "0.0.2" }, + }), + ); + await mkdir(join(workspaceRoot, "packages", "lib"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "lib", "package.json"), + JSON.stringify({ + name: "lib", + version: "1.0.0", + dependencies: { "@wcallahan/javachat-cli": "0.0.2" }, + }), + ); + const packageRoot = join(workspaceRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(workspaceRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(workspaceRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 0); + const invocations = await readNpmInvocations(invocationLog); + assert.equal(invocations.length, 1); + assert.deepEqual(invocations[0].argumentsList, ["install", "@wcallahan/javachat-cli@latest"]); + const expectedMemberDirectories = new Set([ + await realpath(join(workspaceRoot, "packages", "app")), + await realpath(join(workspaceRoot, "packages", "lib")), + ]); + assert.ok( + expectedMemberDirectories.has(invocations[0].workingDirectory), + `npm ran in ${invocations[0].workingDirectory}`, + ); + assert.notEqual(invocations[0].workingDirectory, await realpath(workspaceRoot)); +}); + +test("refuses to update a workspaces root whose members do not declare JavaChat", async (testContext) => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "javachat-cli-workspace-refuse-test-")); + testContext.after(() => rm(workspaceRoot, { recursive: true, force: true })); + await writeFile( + join(workspaceRoot, "package.json"), + JSON.stringify({ + name: "monorepo", + version: "1.0.0", + private: true, + workspaces: ["packages/*"], + }), + ); + await mkdir(join(workspaceRoot, "packages", "app"), { recursive: true }); + await writeFile( + join(workspaceRoot, "packages", "app", "package.json"), + JSON.stringify({ name: "app", version: "1.0.0" }), + ); + const packageRoot = join(workspaceRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(workspaceRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(workspaceRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 1); + assert.match( + cliExecution.standardError, + /The project at .* does not declare @wcallahan\/javachat-cli; npm will not be allowed to modify it\./, + ); + await assert.rejects(readFile(invocationLog, "utf8"), { code: "ENOENT" }); +}); + +test("refuses to update a non-workspaces project that does not declare JavaChat", async (testContext) => { + const projectRoot = await mkdtemp(join(tmpdir(), "javachat-cli-project-refuse-test-")); + testContext.after(() => rm(projectRoot, { recursive: true, force: true })); + await writeFile(join(projectRoot, "package.json"), JSON.stringify({ name: "standalone", version: "1.0.0" })); + const packageRoot = join(projectRoot, "node_modules", "@wcallahan", "javachat-cli"); + const binaryPath = join(projectRoot, "node_modules", ".bin", "javachat"); + await createInstalledCli(packageRoot, binaryPath); + const { fakeBinDirectory, invocationLog } = await createFakeNpm(projectRoot); + + const cliExecution = await runCli( + ["update"], + { + PATH: `${fakeBinDirectory}:${process.env.PATH}`, + TEST_NPM_INVOCATION_LOG: invocationLog, + }, + binaryPath, + ); + + assert.equal(cliExecution.exitCode, 1); + assert.match( + cliExecution.standardError, + /does not declare @wcallahan\/javachat-cli; npm will not be allowed to modify it\./, + ); + await assert.rejects(readFile(invocationLog, "utf8"), { code: "ENOENT" }); +}); + test("prints the installed version without loading credentials", async () => { const cliExecution = await runCli(["--version"], { XDG_CONFIG_HOME: CLI_ENTRYPOINT,