Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 25 additions & 2 deletions scripts/package-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 18 additions & 6 deletions scripts/postinstall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading