diff --git a/package.json b/package.json index 795cf6b..367bf51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@first-tree-ai/context-tree", - "version": "0.1.7", + "version": "0.1.8", "description": "Durable, structured project context for coding agents: a CLI plus framework-neutral skills.", "type": "module", "license": "Apache-2.0", diff --git a/scripts/package-e2e.mjs b/scripts/package-e2e.mjs index ad64dc2..162c062 100644 --- a/scripts/package-e2e.mjs +++ b/scripts/package-e2e.mjs @@ -136,12 +136,35 @@ try { "a local install must not write skills to the home directory", ); - // A global install does, which is the documented path. - const globalPostinstall = spawnSync(process.execPath, [join(installedPackage, "scripts/postinstall.mjs")], { + // Nor does a global install of a package that depends on this one: npm sets npm_config_global + // for its dependencies too, and this copy is nested inside the consumer, which is that layout. + const nestedPostinstall = spawnSync(process.execPath, [join(installedPackage, "scripts/postinstall.mjs")], { cwd: consumerRoot, encoding: "utf8", env: { ...npmEnvironment, npm_config_global: "true" }, }); + assert.equal(nestedPostinstall.status, 0, "postinstall must never fail an install"); + assert.match(nestedPostinstall.stdout, /run `context-tree install`/u); + assert.equal( + existsSync(join(temporaryRoot, ".claude", "skills")), + false, + "a global install of a dependent package must not write skills to the home directory", + ); + + // A direct global install does. Installing for real is what proves it, because the layout npm + // produces is the whole basis of the distinction. + const globalPrefix = join(temporaryRoot, "global-prefix"); + execFileSync("npm", ["install", "-g", "--prefix", globalPrefix, "--no-audit", "--no-fund", tarball], { + cwd: temporaryRoot, + env: npmEnvironment, + stdio: "pipe", + }); + const globallyInstalled = join(globalPrefix, "lib/node_modules/@first-tree-ai/context-tree"); + const globalPostinstall = spawnSync(process.execPath, [join(globallyInstalled, "scripts/postinstall.mjs")], { + cwd: temporaryRoot, + encoding: "utf8", + env: { ...npmEnvironment, npm_config_global: "true" }, + }); assert.equal(globalPostinstall.status, 0, "postinstall must never fail an install"); assert.match(globalPostinstall.stdout, /installed 6 skills for claude/u); for (const skill of SKILLS) { diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index f021f81..74e7443 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -5,20 +5,32 @@ // A failure must never fail `npm install`: the CLI is still usable, and // `context-tree install` can be run by hand afterwards. // -// Only a global install writes to the home directory. Adding this package as a local -// dependency — including this repository's own `pnpm install` — must not silently -// modify the developer's agent configuration, so it just prints the command. +// Only a direct global install writes to the home directory; anything else just prints the +// command, so installing this package as a dependency never touches a developer's own agents. import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -if (process.env.npm_config_global !== "true") { +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// npm sets npm_config_global for a global install's dependencies too, so the flag alone does not +// identify the install target. A dependency copy sits inside the owning package's node_modules; a +// directly installed one sits in npm's own prefix, which is not a package. +function ownedByAnotherPackage(directory) { + const parent = dirname(directory); + if (parent === directory) return false; + if (basename(directory) === "node_modules") return existsSync(join(parent, "package.json")); + return ownedByAnotherPackage(parent); +} + +if (process.env.npm_config_global !== "true" || ownedByAnotherPackage(packageRoot)) { process.stdout.write("Context Tree: run `context-tree install` to add the skills to your agent.\n"); process.exit(0); } -const cli = resolve(dirname(fileURLToPath(import.meta.url)), "..", "dist", "cli", "index.mjs"); +const cli = join(packageRoot, "dist", "cli", "index.mjs"); const result = spawnSync(process.execPath, [cli, "install"], { encoding: "utf8" }); if (result.error !== undefined || result.status !== 0) {