From 1bdbbc3f422654b3b93d9279bc73e9d38e0bc22b Mon Sep 17 00:00:00 2001 From: S Ravi Kumar Date: Sat, 5 Sep 2026 11:32:48 +0530 Subject: [PATCH] Fixed npm issue. --- docs/TechieFlow-Installation.md | 28 ++++++- package.json | 5 +- scripts/install.mjs | 21 +++++ scripts/npm-cleanup.mjs | 137 ++++++++++++++++++++++++++++++++ scripts/npm-postinstall.mjs | 49 ++++++++++++ scripts/test-install.mjs | 64 ++++++++++++++- scripts/validate.mjs | 5 +- 7 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 scripts/npm-cleanup.mjs create mode 100644 scripts/npm-postinstall.mjs diff --git a/docs/TechieFlow-Installation.md b/docs/TechieFlow-Installation.md index b67196a..10aaad6 100644 --- a/docs/TechieFlow-Installation.md +++ b/docs/TechieFlow-Installation.md @@ -30,6 +30,8 @@ You do not need rsync, git on the command line, or a clone of this repository. The installer copies the framework into hidden folders inside your project. It never adds the framework as a dependency of your application. After it runs there is no `node_modules`, no `package.json` and no lock file that was not there before. +The npm package itself holds the framework folders, this document and a `scripts/` folder with the installer's own three files. Nothing from `scripts/` is ever copied into your project, so it cannot clash with a `scripts/` folder of your own. Claude Code and OpenCode never read the package. They read the copies the installer places in your project, listed below. + | Path | What it is | On update | |---|---|---| | `.tfcore/` | The framework: personas, tasks, templates, hooks, helper scripts. | Refreshed. Two files inside are yours and are kept: `core-config.yaml` and `routing.yaml`. | @@ -282,14 +284,36 @@ git clone https://github.com/techierathore/TechieFlow.git The installer and the scripts produce the same files. A test in the repository, `scripts/test-install.mjs`, runs both on the same folders and fails on any difference. Choose the route you like. The package route needs no clone and always gives you the released version. The clone route gives you whatever is on the branch you checked out. -Do not run `npm install @techierathore/techieflow`. That would add the package to your application. Use `npx`, which runs the installer once and keeps nothing. +--- + +## 9. If you ran `npm install` instead of `npx` + +The documented command is `npx ... install`. It downloads the package to npm's cache, runs the installer once, and leaves nothing behind. + +`npm install @techierathore/techieflow` is the wrong command for a framework. It treats the package as a dependency of your application: it puts the whole package under `node_modules/@techierathore/techieflow/`, and writes it into `package.json` and `package-lock.json`. Claude Code and OpenCode do not look there. + +What happens next depends on your npm version. Check with `npm --version`. + +| npm version | What happens | +|---|---| +| 10 or 11 | The package's install hook runs the installer for you, then removes the package from `node_modules/`, `package.json` and `package-lock.json`. If npm created those files only for this install, they are removed too. A JavaScript project keeps its own `package.json`, lock file and `node_modules/`, minus the framework entry. The end result is the same as the `npx` command. npm hides the installer's output, so read `.tf-scaffold-note.txt` for the next step. | +| 12 or newer | npm no longer runs install hooks unless the project allows them. Nothing is installed. The package just sits under `node_modules/`. | + +Either way, the fix is the same. Run the real command in the project folder: + +```bash +npx @techierathore/techieflow@latest install +``` + +It installs the framework if it is not there yet, then removes the leftover package from `node_modules/`, `package.json` and `package-lock.json`. `update` does the same tidy-up. --- -## 9. If something goes wrong +## 10. If something goes wrong | What you see | What to do | |---|---| +| The framework is under `node_modules/@techierathore/techieflow/` and nowhere else | You ran `npm install`. See section 9. Run `npx @techierathore/techieflow@latest install`. | | `bash was not found` | On Windows, run the command inside WSL or Git Bash. | | `python3 was not found` | Install Python 3 and run the command again. On macOS: `brew install python3`. On Ubuntu or WSL: `sudo apt-get install -y python3`. | | `Codex bindings could not be generated` | Your Python is older than 3.10. Everything except the Codex files is installed. Upgrade Python and run `update` to add them. | diff --git a/package.json b/package.json index 16c3978..5573404 100644 --- a/package.json +++ b/package.json @@ -39,13 +39,14 @@ "opencode.jsonc", "WORKFLOW.html", "scripts/install.mjs", - "scripts/test-install.mjs", - "scripts/validate.mjs", + "scripts/npm-postinstall.mjs", + "scripts/npm-cleanup.mjs", "docs/TechieFlow-Installation.md", "LICENSE", "README.md" ], "scripts": { + "postinstall": "node scripts/npm-postinstall.mjs", "validate": "node scripts/validate.mjs", "test:install": "node scripts/test-install.mjs", "pack:check": "npm pack --dry-run", diff --git a/scripts/install.mjs b/scripts/install.mjs index c50859d..3c5056c 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -31,6 +31,7 @@ import { homedir } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; +import { hasDependencyFootprint, removeDependencyFootprint } from "./npm-cleanup.mjs"; // ---------------------------------------------------------------- arguments @@ -209,6 +210,22 @@ function checkTools() { } } +// Someone ran `npm install @techierathore/techieflow` instead of the npx command. On npm 10 +// and 11 the package's install hook deploys the framework and removes its own npm files. +// npm 12 and newer skip install hooks, so the package just sits under node_modules/ and in +// package.json. Whichever way it happened, once the framework is in place those npm files +// have no purpose: remove them. Skipped while the install hook itself is running (the +// package folder is still in use by npm then; the hook's own cleanup handles that case). +function tidyDependencyInstall() { + const packageDir = join(target, "node_modules", "@techierathore", "techieflow"); + if (sourceRoot === packageDir || sourceRoot.startsWith(`${packageDir}${sep}`)) return; + if (!hasDependencyFootprint(target)) return; + say(""); + say(" The package was found under node_modules/ (someone ran `npm install` on it). The framework is not an application dependency."); + if (dryRun) { say(" WOULD remove it from node_modules/, package.json and package-lock.json."); return; } + for (const item of removeDependencyFootprint(target)) say(` removed ${item}`); +} + function ensureSafeTarget() { if (target === sourceRoot || target.startsWith(`${sourceRoot}${sep}`)) throw new Error("Refusing to install into the framework itself. Pass --target=."); if (target === resolve("/") || target === resolve(homedir())) throw new Error("Refusing to install into the filesystem root or your home folder."); @@ -624,6 +641,8 @@ async function install() { shimLegacyPersonas({ gapOnly: false }); // 9. .gitignore, build-output audit, telemetry, .gitattributes deployHousekeeping(); + // 10. leftovers of a plain `npm install` of this package, if any + tidyDependencyInstall(); say(""); if (dryRun) { say("✔ Dry run complete — no files changed."); return; } @@ -863,6 +882,8 @@ function update() { reportStaleReferences(); // 8. .gitignore, build-output audit, telemetry, .gitattributes deployHousekeeping(); + // 9. leftovers of a plain `npm install` of this package, if any + tidyDependencyInstall(); say(""); if (dryRun) { diff --git a/scripts/npm-cleanup.mjs b/scripts/npm-cleanup.mjs new file mode 100644 index 0000000..2ac9e0b --- /dev/null +++ b/scripts/npm-cleanup.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// scripts/npm-cleanup.mjs — removes the package's own footprint after a plain `npm install`. +// +// The framework is not an application dependency. `npm install @techierathore/techieflow` +// still puts the package under node_modules/ and writes it into package.json and +// package-lock.json. Once the framework files are in place, this removes that footprint: +// +// node_modules/@techierathore/techieflow the package folder +// node_modules/@techierathore when nothing else is in it +// node_modules/.bin/techieflow* the command shims +// the package's entry in package.json, package-lock.json and node_modules/.package-lock.json +// +// If npm created package.json, package-lock.json or node_modules only for this install +// (an empty folder, or a .NET project), they are removed as well. A project's own +// package.json, lock file and node_modules are kept, minus the entry for this package. +// +// Used two ways: +// - by npm-postinstall.mjs, detached, after the install hook has deployed the framework. +// npm writes package.json, package-lock.json and node_modules/.package-lock.json AFTER +// the hook has run, so the detached process waits for that last write before it starts. +// - by install.mjs, straight away, when the package is found under node_modules/ of the +// project it just installed into. That happens when the owner ran `npm install` on an +// npm that skips install hooks (npm 12 and newer) and then ran the real command. + +import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const packageName = "@techierathore/techieflow"; +const dependencyKeys = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; +const lockKey = `node_modules/${packageName}`; + +function readJson(path) { + try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } +} + +const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); + +// True for a package.json that npm created for this install alone: nothing in it except +// empty dependency tables once this package's entry is gone. +function isEmptyManifest(manifest) { + return Object.entries(manifest).every(([key, value]) => + dependencyKeys.includes(key) && value && typeof value === "object" && Object.keys(value).length === 0); +} + +// Drops this package from a package.json object. Returns true when something was removed. +function dropFromManifest(manifest) { + let changed = false; + for (const key of dependencyKeys) { + if (manifest?.[key] && packageName in manifest[key]) { delete manifest[key][packageName]; changed = true; } + } + return changed; +} + +// Drops this package from a package-lock.json object (root file or the hidden one in node_modules). +function dropFromLock(lock) { + let changed = false; + if (lock?.packages) { + if (lockKey in lock.packages) { delete lock.packages[lockKey]; changed = true; } + if (lock.packages[""] && dropFromManifest(lock.packages[""])) changed = true; + } + if (lock?.dependencies && packageName in lock.dependencies) { delete lock.dependencies[packageName]; changed = true; } + return changed; +} + +// Does this project folder hold the package as an npm dependency artifact? +export function hasDependencyFootprint(target) { + const manifest = readJson(join(target, "package.json")); + return existsSync(join(target, "node_modules", "@techierathore", "techieflow")) + || dependencyKeys.some((key) => manifest?.[key] && packageName in manifest[key]); +} + +// Removes the footprint. Returns the list of things removed, in plain words, for the caller to print. +export function removeDependencyFootprint(target) { + const removed = []; + const nm = join(target, "node_modules"); + const scopeDir = join(nm, "@techierathore"); + const packageDir = join(scopeDir, "techieflow"); + const bin = join(nm, ".bin"); + + if (existsSync(packageDir)) { rmSync(packageDir, { recursive: true, force: true }); removed.push("node_modules/@techierathore/techieflow/"); } + if (existsSync(scopeDir) && readdirSync(scopeDir).every((e) => e === ".DS_Store")) rmSync(scopeDir, { recursive: true, force: true }); + if (existsSync(bin)) { + for (const shim of readdirSync(bin)) { + if (shim === "techieflow" || shim.startsWith("techieflow.")) { rmSync(join(bin, shim), { force: true }); removed.push(`node_modules/.bin/${shim}`); } + } + } + + const manifestPath = join(target, "package.json"); + const manifest = readJson(manifestPath); + let manifestRemoved = false; + if (manifest && dropFromManifest(manifest)) { + if (isEmptyManifest(manifest)) { rmSync(manifestPath, { force: true }); manifestRemoved = true; removed.push("package.json (npm created it for this install)"); } + else { writeJson(manifestPath, manifest); removed.push(`the ${packageName} entry in package.json`); } + } + + const lockPath = join(target, "package-lock.json"); + if (manifestRemoved) { + if (existsSync(lockPath)) { rmSync(lockPath, { force: true }); removed.push("package-lock.json (npm created it for this install)"); } + } else { + const lock = readJson(lockPath); + if (lock && dropFromLock(lock)) { writeJson(lockPath, lock); removed.push(`the ${packageName} entry in package-lock.json`); } + } + + if (existsSync(nm)) { + const bookkeeping = (entry) => entry === ".package-lock.json" || entry === ".DS_Store" + || (entry === ".bin" && readdirSync(bin).every((e) => e === ".DS_Store")); + if (readdirSync(nm).every(bookkeeping)) { rmSync(nm, { recursive: true, force: true }); removed.push("node_modules/ (npm created it for this install)"); } + else { + const hiddenPath = join(nm, ".package-lock.json"); + const hidden = readJson(hiddenPath); + if (hidden && dropFromLock(hidden)) writeJson(hiddenPath, hidden); + } + } + return removed; +} + +// ---- standalone use: node npm-cleanup.mjs , started detached by the install hook. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const target = process.argv[2]; + if (!target || !existsSync(target)) process.exit(0); + const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); + const hiddenLock = join(target, "node_modules", ".package-lock.json"); + // npm's last write is node_modules/.package-lock.json. Wait for it to name this package, + // then a moment longer for npm to close it. Give up waiting after 60 seconds (for example + // when npm was told not to write a lock file) and clean up anyway. + for (let waited = 0; waited < 60_000; waited += 200) { + const hidden = readJson(hiddenLock); + if (hidden?.packages && lockKey in hidden.packages) break; + await sleep(200); + } + await sleep(500); + // On Windows npm may still hold a handle for a moment; retry briefly. + for (let attempt = 0; attempt < 50; attempt++) { + try { removeDependencyFootprint(target); break; } catch { await sleep(200); } + } +} diff --git a/scripts/npm-postinstall.mjs b/scripts/npm-postinstall.mjs new file mode 100644 index 0000000..4919a39 --- /dev/null +++ b/scripts/npm-postinstall.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// scripts/npm-postinstall.mjs — the install hook, for when someone runs +// +// npm install @techierathore/techieflow +// +// instead of the documented `npx @techierathore/techieflow@latest install`. npm runs this +// hook after it has put the package under node_modules/. The hook installs the framework +// into the project (the folder npm was run in), then starts npm-cleanup.mjs detached to +// remove the package from node_modules/, package.json and package-lock.json once npm has +// finished writing them. The result is the same as the npx command: the framework's hidden +// folders, and no npm files that were not there before. +// +// npm 12 and newer do not run install hooks unless the project allows them, so on those +// versions a plain `npm install` only places the package under node_modules/. The fix is +// to run the documented npx command, which installs the framework and tidies that up. +// +// The hook does nothing when the package is not being installed as a dependency of some +// other folder: `npx`, `npm exec`, `npm pack`, `npm publish` and `npm install` inside +// this repository all skip it. + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const target = process.env.INIT_CWD ? resolve(process.env.INIT_CWD) : null; +const dependencyInstall = ["install", "ci"].includes(process.env.npm_command) + && target !== null + && target !== packageRoot + && existsSync(join(target, "node_modules", "@techierathore", "techieflow")); + +if (!dependencyInstall) process.exit(0); + +const installed = spawnSync(process.execPath, [join(packageRoot, "scripts", "install.mjs"), "install", `--target=${target}`], { + cwd: target, + env: process.env, + stdio: "inherit", +}); +if (installed.status !== 0) process.exit(installed.status ?? 1); + +const cleanup = spawn(process.execPath, [join(packageRoot, "scripts", "npm-cleanup.mjs"), target], { + cwd: target, + detached: true, + stdio: "ignore", + windowsHide: true, +}); +cleanup.unref(); +console.log("TechieFlow installed. The temporary npm files (node_modules, package.json, package-lock.json entries) are being removed."); diff --git a/scripts/test-install.mjs b/scripts/test-install.mjs index ee9ccd9..8b07cd3 100644 --- a/scripts/test-install.mjs +++ b/scripts/test-install.mjs @@ -14,7 +14,8 @@ // 3. It checks the installed Claude mirror against .tfcore/ and every {file:} reference // in both installed opencode.jsonc files. // 4. It checks uninstall, --dry-run, --no-gitignore, --keep-permissions, a second run, -// and the one-shot `npm exec` form that `npx` uses. +// the one-shot `npm exec` form that `npx` uses, and the plain `npm install` form +// (install hook plus cleanup, in an empty folder and in a JavaScript project). // // Every check runs even if an earlier one fails; the summary at the end lists them all. // Exit code 0 means every check passed. @@ -139,6 +140,16 @@ function assertUnchanged(before, directory, what) { const read = (path) => readFileSync(path, "utf8"); const lines = (path) => read(path).replace(/\r/g, "").split("\n"); +// Polls until the condition holds. Returns false when the time runs out. +async function waitFor(condition, timeoutMs) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (condition()) return true; + await new Promise((done) => setTimeout(done, 200)); + } + return condition(); +} + // Every file under .claude/commands/TechieFlow// must equal .tfcore// byte for byte. function mirrorDifferences(project) { const core = join(project, ".tfcore"); @@ -487,6 +498,57 @@ try { assertSameTree(brownNpm, npxTarget); for (const path of ["node_modules", "package.json", "package-lock.json"]) assert(!existsSync(join(npxTarget, path)), `${path} left behind by npm exec`); }); + + // ---- the wrong-but-common form: `npm install @techierathore/techieflow` + // On npm 10 and 11 the package's install hook deploys the framework and a detached + // cleanup removes the npm files once npm has finished. The cleanup runs after npm exits, + // so these checks wait for it. npm 12 skips install hooks; `--ignore-scripts` below + // stands in for that, and the documented npx command must then finish the job. + const npmArtifacts = ["node_modules", "package.json", "package-lock.json"]; + const settled = (dir, done) => waitFor(() => done(dir), 30_000); + + const plainTarget = join(sandbox, "plain-npm-install"); + seedProject(plainTarget); + npm(["install", tarball], { cwd: plainTarget }); + const plainSettled = await settled(plainTarget, (dir) => npmArtifacts.every((p) => !existsSync(join(dir, p)))); + check("plain `npm install` of the tarball installs the framework and removes its own npm files", () => { + assert(plainSettled, `after 30 seconds the target still holds ${npmArtifacts.filter((p) => existsSync(join(plainTarget, p))).join(", ")}`); + assertSameTree(brownNpm, plainTarget); + }); + + const jsTarget = join(sandbox, "plain-npm-install-js-project"); + seedProject(jsTarget); + mkdirSync(join(jsTarget, "vendor", "leftpad"), { recursive: true }); + writeFileSync(join(jsTarget, "vendor", "leftpad", "package.json"), '{ "name": "leftpad", "version": "1.0.0" }\n'); + const ownManifest = { name: "myapp", version: "0.1.0", scripts: { build: "echo build" }, dependencies: { leftpad: "file:./vendor/leftpad" } }; + writeFileSync(join(jsTarget, "package.json"), `${JSON.stringify(ownManifest, null, 2)}\n`); + npm(["install"], { cwd: jsTarget }); + npm(["install", tarball], { cwd: jsTarget }); + const jsSettled = await settled(jsTarget, (dir) => !existsSync(join(dir, "node_modules", "@techierathore")) && !read(join(dir, "package.json")).includes("techieflow")); + check("plain `npm install` in a JavaScript project keeps the project's own package.json, lock file and node_modules", () => { + assert(jsSettled, "after 30 seconds the package was still in node_modules/ or package.json"); + const manifest = JSON.parse(read(join(jsTarget, "package.json"))); + assert(manifest.name === "myapp" && manifest.scripts.build === "echo build" && manifest.dependencies.leftpad === "file:./vendor/leftpad", "the project's package.json was changed beyond removing the framework entry"); + assert(!("@techierathore/techieflow" in manifest.dependencies), "package.json still lists the framework"); + assert(!read(join(jsTarget, "package-lock.json")).includes("@techierathore/techieflow"), "package-lock.json still lists the framework"); + assert(existsSync(join(jsTarget, "node_modules", "leftpad")), "the project's own dependency was removed from node_modules"); + assert(!existsSync(join(jsTarget, "node_modules", "@techierathore")), "the framework package is still under node_modules"); + assert(!existsSync(join(jsTarget, "node_modules", ".bin", "techieflow")), "the techieflow shim is still under node_modules/.bin"); + for (const path of [".tfcore/agents/analyst.md", ".claude/commands/TechieFlow/agents/analyst.md", ".opencode/opencode.jsonc", "WORKFLOW.html"]) { + assert(existsSync(join(jsTarget, path)), `${path} was not installed`); + } + }); + + const noHooksTarget = join(sandbox, "npm-install-without-hooks"); + seedProject(noHooksTarget); + npm(["install", "--ignore-scripts", tarball], { cwd: noHooksTarget }); + check("`npm install` on an npm that skips install hooks (npm 12): the npx command then installs and tidies up", () => { + assert(existsSync(join(noHooksTarget, "node_modules", "@techierathore", "techieflow", "package.json")), "test setup: the package did not land under node_modules"); + assert(!existsSync(join(noHooksTarget, ".tfcore")), "test setup: the install hook ran although scripts were ignored"); + npm(["exec", "--yes", `--package=${tarball}`, "--", "techieflow", "install"], { cwd: noHooksTarget }); + for (const path of npmArtifacts) assert(!existsSync(join(noHooksTarget, path)), `${path} was left behind`); + assertSameTree(brownNpm, noHooksTarget); + }); } catch (error) { failures.push(`setup: ${error.message}`); console.log(`FAIL setup\n ${error.message.split("\n").join("\n ")}`); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 69216b4..d1d74b8 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -130,12 +130,15 @@ const mustNotShip = [ ".opencode/node_modules", ".opencode/package.json", ".opencode/package-lock.json", ".opencode/.gitignore", ".codex/agents", ".agents", ".techierag", ".trblazeui", ".github", "scaffold-brownfield.sh", "scaffold-greenfield.sh", "update-framework.sh", + "scripts/test-install.mjs", "scripts/validate.mjs", "docs/TechieFlow-Requirements.md", "docs/TechieFlow-How-It-Works.md", "docs/metrics", "DECISIONS.md", "WorkFlow-Context.md", "CodexChanges.md", ]; +// The package ships the framework plus the installer's own three files under scripts/. +// Those never reach a project: the installer copies the framework folders only. const mustShip = [ "package.json", "LICENSE", "README.md", "docs/TechieFlow-Installation.md", - "scripts/install.mjs", "scripts/test-install.mjs", "scripts/validate.mjs", + "scripts/install.mjs", "scripts/npm-postinstall.mjs", "scripts/npm-cleanup.mjs", ]; check("npm pack --dry-run succeeds and ships the right files", () => {