@techierathore/techieflow 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,8 @@ You do not need rsync, git on the command line, or a clone of this repository.
30
30
 
31
31
  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.
32
32
 
33
+ 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.
34
+
33
35
  | Path | What it is | On update |
34
36
  |---|---|---|
35
37
  | `.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
282
284
 
283
285
  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.
284
286
 
285
- 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.
287
+ ---
288
+
289
+ ## 9. If you ran `npm install` instead of `npx`
290
+
291
+ The documented command is `npx ... install`. It downloads the package to npm's cache, runs the installer once, and leaves nothing behind.
292
+
293
+ `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.
294
+
295
+ What happens next depends on your npm version. Check with `npm --version`.
296
+
297
+ | npm version | What happens |
298
+ |---|---|
299
+ | 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. |
300
+ | 12 or newer | npm no longer runs install hooks unless the project allows them. Nothing is installed. The package just sits under `node_modules/`. |
301
+
302
+ Either way, the fix is the same. Run the real command in the project folder:
303
+
304
+ ```bash
305
+ npx @techierathore/techieflow@latest install
306
+ ```
307
+
308
+ 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.
286
309
 
287
310
  ---
288
311
 
289
- ## 9. If something goes wrong
312
+ ## 10. If something goes wrong
290
313
 
291
314
  | What you see | What to do |
292
315
  |---|---|
316
+ | The framework is under `node_modules/@techierathore/techieflow/` and nowhere else | You ran `npm install`. See section 9. Run `npx @techierathore/techieflow@latest install`. |
293
317
  | `bash was not found` | On Windows, run the command inside WSL or Git Bash. |
294
318
  | `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`. |
295
319
  | `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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techierathore/techieflow",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "TechieFlow: a spec-driven development framework for AI coding agents, for Claude Code and OpenCode. One command installs it into any project.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -39,13 +39,14 @@
39
39
  "opencode.jsonc",
40
40
  "WORKFLOW.html",
41
41
  "scripts/install.mjs",
42
- "scripts/test-install.mjs",
43
- "scripts/validate.mjs",
42
+ "scripts/npm-postinstall.mjs",
43
+ "scripts/npm-cleanup.mjs",
44
44
  "docs/TechieFlow-Installation.md",
45
45
  "LICENSE",
46
46
  "README.md"
47
47
  ],
48
48
  "scripts": {
49
+ "postinstall": "node scripts/npm-postinstall.mjs",
49
50
  "validate": "node scripts/validate.mjs",
50
51
  "test:install": "node scripts/test-install.mjs",
51
52
  "pack:check": "npm pack --dry-run",
@@ -31,6 +31,7 @@ import { homedir } from "node:os";
31
31
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
32
32
  import { createInterface } from "node:readline/promises";
33
33
  import { fileURLToPath } from "node:url";
34
+ import { hasDependencyFootprint, removeDependencyFootprint } from "./npm-cleanup.mjs";
34
35
 
35
36
  // ---------------------------------------------------------------- arguments
36
37
 
@@ -209,6 +210,22 @@ function checkTools() {
209
210
  }
210
211
  }
211
212
 
213
+ // Someone ran `npm install @techierathore/techieflow` instead of the npx command. On npm 10
214
+ // and 11 the package's install hook deploys the framework and removes its own npm files.
215
+ // npm 12 and newer skip install hooks, so the package just sits under node_modules/ and in
216
+ // package.json. Whichever way it happened, once the framework is in place those npm files
217
+ // have no purpose: remove them. Skipped while the install hook itself is running (the
218
+ // package folder is still in use by npm then; the hook's own cleanup handles that case).
219
+ function tidyDependencyInstall() {
220
+ const packageDir = join(target, "node_modules", "@techierathore", "techieflow");
221
+ if (sourceRoot === packageDir || sourceRoot.startsWith(`${packageDir}${sep}`)) return;
222
+ if (!hasDependencyFootprint(target)) return;
223
+ say("");
224
+ say(" The package was found under node_modules/ (someone ran `npm install` on it). The framework is not an application dependency.");
225
+ if (dryRun) { say(" WOULD remove it from node_modules/, package.json and package-lock.json."); return; }
226
+ for (const item of removeDependencyFootprint(target)) say(` removed ${item}`);
227
+ }
228
+
212
229
  function ensureSafeTarget() {
213
230
  if (target === sourceRoot || target.startsWith(`${sourceRoot}${sep}`)) throw new Error("Refusing to install into the framework itself. Pass --target=<your project folder>.");
214
231
  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() {
624
641
  shimLegacyPersonas({ gapOnly: false });
625
642
  // 9. .gitignore, build-output audit, telemetry, .gitattributes
626
643
  deployHousekeeping();
644
+ // 10. leftovers of a plain `npm install` of this package, if any
645
+ tidyDependencyInstall();
627
646
 
628
647
  say("");
629
648
  if (dryRun) { say("✔ Dry run complete — no files changed."); return; }
@@ -863,6 +882,8 @@ function update() {
863
882
  reportStaleReferences();
864
883
  // 8. .gitignore, build-output audit, telemetry, .gitattributes
865
884
  deployHousekeeping();
885
+ // 9. leftovers of a plain `npm install` of this package, if any
886
+ tidyDependencyInstall();
866
887
 
867
888
  say("");
868
889
  if (dryRun) {
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ // scripts/npm-cleanup.mjs — removes the package's own footprint after a plain `npm install`.
3
+ //
4
+ // The framework is not an application dependency. `npm install @techierathore/techieflow`
5
+ // still puts the package under node_modules/ and writes it into package.json and
6
+ // package-lock.json. Once the framework files are in place, this removes that footprint:
7
+ //
8
+ // node_modules/@techierathore/techieflow the package folder
9
+ // node_modules/@techierathore when nothing else is in it
10
+ // node_modules/.bin/techieflow* the command shims
11
+ // the package's entry in package.json, package-lock.json and node_modules/.package-lock.json
12
+ //
13
+ // If npm created package.json, package-lock.json or node_modules only for this install
14
+ // (an empty folder, or a .NET project), they are removed as well. A project's own
15
+ // package.json, lock file and node_modules are kept, minus the entry for this package.
16
+ //
17
+ // Used two ways:
18
+ // - by npm-postinstall.mjs, detached, after the install hook has deployed the framework.
19
+ // npm writes package.json, package-lock.json and node_modules/.package-lock.json AFTER
20
+ // the hook has run, so the detached process waits for that last write before it starts.
21
+ // - by install.mjs, straight away, when the package is found under node_modules/ of the
22
+ // project it just installed into. That happens when the owner ran `npm install` on an
23
+ // npm that skips install hooks (npm 12 and newer) and then ran the real command.
24
+
25
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { pathToFileURL } from "node:url";
28
+
29
+ export const packageName = "@techierathore/techieflow";
30
+ const dependencyKeys = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
31
+ const lockKey = `node_modules/${packageName}`;
32
+
33
+ function readJson(path) {
34
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
35
+ }
36
+
37
+ const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
38
+
39
+ // True for a package.json that npm created for this install alone: nothing in it except
40
+ // empty dependency tables once this package's entry is gone.
41
+ function isEmptyManifest(manifest) {
42
+ return Object.entries(manifest).every(([key, value]) =>
43
+ dependencyKeys.includes(key) && value && typeof value === "object" && Object.keys(value).length === 0);
44
+ }
45
+
46
+ // Drops this package from a package.json object. Returns true when something was removed.
47
+ function dropFromManifest(manifest) {
48
+ let changed = false;
49
+ for (const key of dependencyKeys) {
50
+ if (manifest?.[key] && packageName in manifest[key]) { delete manifest[key][packageName]; changed = true; }
51
+ }
52
+ return changed;
53
+ }
54
+
55
+ // Drops this package from a package-lock.json object (root file or the hidden one in node_modules).
56
+ function dropFromLock(lock) {
57
+ let changed = false;
58
+ if (lock?.packages) {
59
+ if (lockKey in lock.packages) { delete lock.packages[lockKey]; changed = true; }
60
+ if (lock.packages[""] && dropFromManifest(lock.packages[""])) changed = true;
61
+ }
62
+ if (lock?.dependencies && packageName in lock.dependencies) { delete lock.dependencies[packageName]; changed = true; }
63
+ return changed;
64
+ }
65
+
66
+ // Does this project folder hold the package as an npm dependency artifact?
67
+ export function hasDependencyFootprint(target) {
68
+ const manifest = readJson(join(target, "package.json"));
69
+ return existsSync(join(target, "node_modules", "@techierathore", "techieflow"))
70
+ || dependencyKeys.some((key) => manifest?.[key] && packageName in manifest[key]);
71
+ }
72
+
73
+ // Removes the footprint. Returns the list of things removed, in plain words, for the caller to print.
74
+ export function removeDependencyFootprint(target) {
75
+ const removed = [];
76
+ const nm = join(target, "node_modules");
77
+ const scopeDir = join(nm, "@techierathore");
78
+ const packageDir = join(scopeDir, "techieflow");
79
+ const bin = join(nm, ".bin");
80
+
81
+ if (existsSync(packageDir)) { rmSync(packageDir, { recursive: true, force: true }); removed.push("node_modules/@techierathore/techieflow/"); }
82
+ if (existsSync(scopeDir) && readdirSync(scopeDir).every((e) => e === ".DS_Store")) rmSync(scopeDir, { recursive: true, force: true });
83
+ if (existsSync(bin)) {
84
+ for (const shim of readdirSync(bin)) {
85
+ if (shim === "techieflow" || shim.startsWith("techieflow.")) { rmSync(join(bin, shim), { force: true }); removed.push(`node_modules/.bin/${shim}`); }
86
+ }
87
+ }
88
+
89
+ const manifestPath = join(target, "package.json");
90
+ const manifest = readJson(manifestPath);
91
+ let manifestRemoved = false;
92
+ if (manifest && dropFromManifest(manifest)) {
93
+ if (isEmptyManifest(manifest)) { rmSync(manifestPath, { force: true }); manifestRemoved = true; removed.push("package.json (npm created it for this install)"); }
94
+ else { writeJson(manifestPath, manifest); removed.push(`the ${packageName} entry in package.json`); }
95
+ }
96
+
97
+ const lockPath = join(target, "package-lock.json");
98
+ if (manifestRemoved) {
99
+ if (existsSync(lockPath)) { rmSync(lockPath, { force: true }); removed.push("package-lock.json (npm created it for this install)"); }
100
+ } else {
101
+ const lock = readJson(lockPath);
102
+ if (lock && dropFromLock(lock)) { writeJson(lockPath, lock); removed.push(`the ${packageName} entry in package-lock.json`); }
103
+ }
104
+
105
+ if (existsSync(nm)) {
106
+ const bookkeeping = (entry) => entry === ".package-lock.json" || entry === ".DS_Store"
107
+ || (entry === ".bin" && readdirSync(bin).every((e) => e === ".DS_Store"));
108
+ if (readdirSync(nm).every(bookkeeping)) { rmSync(nm, { recursive: true, force: true }); removed.push("node_modules/ (npm created it for this install)"); }
109
+ else {
110
+ const hiddenPath = join(nm, ".package-lock.json");
111
+ const hidden = readJson(hiddenPath);
112
+ if (hidden && dropFromLock(hidden)) writeJson(hiddenPath, hidden);
113
+ }
114
+ }
115
+ return removed;
116
+ }
117
+
118
+ // ---- standalone use: node npm-cleanup.mjs <project folder>, started detached by the install hook.
119
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
120
+ const target = process.argv[2];
121
+ if (!target || !existsSync(target)) process.exit(0);
122
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
123
+ const hiddenLock = join(target, "node_modules", ".package-lock.json");
124
+ // npm's last write is node_modules/.package-lock.json. Wait for it to name this package,
125
+ // then a moment longer for npm to close it. Give up waiting after 60 seconds (for example
126
+ // when npm was told not to write a lock file) and clean up anyway.
127
+ for (let waited = 0; waited < 60_000; waited += 200) {
128
+ const hidden = readJson(hiddenLock);
129
+ if (hidden?.packages && lockKey in hidden.packages) break;
130
+ await sleep(200);
131
+ }
132
+ await sleep(500);
133
+ // On Windows npm may still hold a handle for a moment; retry briefly.
134
+ for (let attempt = 0; attempt < 50; attempt++) {
135
+ try { removeDependencyFootprint(target); break; } catch { await sleep(200); }
136
+ }
137
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // scripts/npm-postinstall.mjs — the install hook, for when someone runs
3
+ //
4
+ // npm install @techierathore/techieflow
5
+ //
6
+ // instead of the documented `npx @techierathore/techieflow@latest install`. npm runs this
7
+ // hook after it has put the package under node_modules/. The hook installs the framework
8
+ // into the project (the folder npm was run in), then starts npm-cleanup.mjs detached to
9
+ // remove the package from node_modules/, package.json and package-lock.json once npm has
10
+ // finished writing them. The result is the same as the npx command: the framework's hidden
11
+ // folders, and no npm files that were not there before.
12
+ //
13
+ // npm 12 and newer do not run install hooks unless the project allows them, so on those
14
+ // versions a plain `npm install` only places the package under node_modules/. The fix is
15
+ // to run the documented npx command, which installs the framework and tidies that up.
16
+ //
17
+ // The hook does nothing when the package is not being installed as a dependency of some
18
+ // other folder: `npx`, `npm exec`, `npm pack`, `npm publish` and `npm install` inside
19
+ // this repository all skip it.
20
+
21
+ import { spawn, spawnSync } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { dirname, join, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
+ const target = process.env.INIT_CWD ? resolve(process.env.INIT_CWD) : null;
28
+ const dependencyInstall = ["install", "ci"].includes(process.env.npm_command)
29
+ && target !== null
30
+ && target !== packageRoot
31
+ && existsSync(join(target, "node_modules", "@techierathore", "techieflow"));
32
+
33
+ if (!dependencyInstall) process.exit(0);
34
+
35
+ const installed = spawnSync(process.execPath, [join(packageRoot, "scripts", "install.mjs"), "install", `--target=${target}`], {
36
+ cwd: target,
37
+ env: process.env,
38
+ stdio: "inherit",
39
+ });
40
+ if (installed.status !== 0) process.exit(installed.status ?? 1);
41
+
42
+ const cleanup = spawn(process.execPath, [join(packageRoot, "scripts", "npm-cleanup.mjs"), target], {
43
+ cwd: target,
44
+ detached: true,
45
+ stdio: "ignore",
46
+ windowsHide: true,
47
+ });
48
+ cleanup.unref();
49
+ console.log("TechieFlow installed. The temporary npm files (node_modules, package.json, package-lock.json entries) are being removed.");
@@ -1,505 +0,0 @@
1
- #!/usr/bin/env node
2
- // scripts/test-install.mjs — proves that the npm installer produces exactly the same
3
- // files as the three shell scripts.
4
- //
5
- // How it works, in plain words:
6
- // 1. It packs this repository with `npm pack` and unpacks the result into a temporary
7
- // folder. That unpacked copy is a clean copy of the framework: it holds only the
8
- // files the package ships, none of the local-only files a working clone collects
9
- // (session files, node_modules, personal settings). The three shell scripts are
10
- // copied next to it so they can run from that clean copy.
11
- // 2. For brownfield, greenfield, update and the old .bmad-core layout, it runs the shell
12
- // script on one folder and the installer on a second, identical folder, then
13
- // compares every file in both (path, content, executable bit). Any difference fails.
14
- // 3. It checks the installed Claude mirror against .tfcore/ and every {file:} reference
15
- // in both installed opencode.jsonc files.
16
- // 4. It checks uninstall, --dry-run, --no-gitignore, --keep-permissions, a second run,
17
- // and the one-shot `npm exec` form that `npx` uses.
18
- //
19
- // Every check runs even if an earlier one fails; the summary at the end lists them all.
20
- // Exit code 0 means every check passed.
21
- //
22
- // Needs: node, npm, bash, python3 and rsync (rsync only because the shell scripts use it).
23
- // Pass --keep to leave the temporary folder on disk for inspection.
24
-
25
- import { spawnSync } from "node:child_process";
26
- import { createHash } from "node:crypto";
27
- import {
28
- appendFileSync, chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync,
29
- readFileSync, readdirSync, readlinkSync, rmSync, statSync, writeFileSync,
30
- } from "node:fs";
31
- import { tmpdir } from "node:os";
32
- import { basename, dirname, join, relative, resolve } from "node:path";
33
- import { fileURLToPath } from "node:url";
34
-
35
- const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
36
- const installer = join(root, "scripts", "install.mjs");
37
- const keepSandbox = process.argv.includes("--keep");
38
- const sandbox = mkdtempSync(join(tmpdir(), "techieflow-install-"));
39
- const failures = [];
40
- const notes = [];
41
- let passed = 0;
42
-
43
- // The Codex binder (.tfcore/utils/tf-codex-bind.py) needs Python 3.10 or newer. On an older
44
- // Python both routes print a warning and skip the Codex agents and skills, so those files are
45
- // only expected when the binder can run.
46
- const pythonVersion = (() => {
47
- const result = spawnSync("python3", ["--version"], { encoding: "utf8" });
48
- const match = /Python (\d+)\.(\d+)/.exec(`${result.stdout ?? ""}${result.stderr ?? ""}`);
49
- return match ? `${match[1]}.${match[2]}` : "unknown";
50
- })();
51
- const codexBinderRuns = (() => {
52
- const [major, minor] = pythonVersion.split(".").map(Number);
53
- return major > 3 || (major === 3 && minor >= 10);
54
- })();
55
- if (!codexBinderRuns) notes.push(`python3 is ${pythonVersion}; the Codex binder needs 3.10 or newer, so neither route generates .codex/agents/ or .agents/skills/ on this machine (CI does).`);
56
-
57
- // ---------------------------------------------------------------- helpers
58
-
59
- function check(name, fn) {
60
- try {
61
- fn();
62
- passed++;
63
- console.log(`ok ${name}`);
64
- } catch (error) {
65
- failures.push(`${name}\n ${error.message.split("\n").join("\n ")}`);
66
- console.log(`FAIL ${name}\n ${error.message.split("\n").join("\n ")}`);
67
- }
68
- }
69
-
70
- function assert(condition, message) {
71
- if (!condition) throw new Error(message);
72
- }
73
-
74
- function run(command, args, options = {}) {
75
- const result = spawnSync(command, args, {
76
- cwd: options.cwd ?? root,
77
- encoding: "utf8",
78
- input: "", // stdin is not a terminal, so the shell scripts never wait for an answer
79
- env: { ...process.env, TF_NO_INSTALL: "1", ...options.env },
80
- shell: options.shell ?? false,
81
- });
82
- if (result.error) throw result.error;
83
- if (result.status !== 0 && !options.allowFailure) {
84
- throw new Error(`${command} ${args.join(" ")} exited with ${result.status}\n${result.stdout}${result.stderr}`);
85
- }
86
- return result;
87
- }
88
-
89
- const node = (args, options) => run(process.execPath, args, options);
90
-
91
- function npm(args, options = {}) {
92
- if (process.env.npm_execpath) return node([process.env.npm_execpath, ...args], options);
93
- const onWindows = process.platform === "win32";
94
- return run(onWindows ? "npm.cmd" : "npm", args, { ...options, shell: onWindows });
95
- }
96
-
97
- const shell = (script, args, options) => run("bash", [script, ...args], options);
98
-
99
- // A snapshot maps every relative path in a folder to what it is: a directory, a link, or a
100
- // file described by its executable bit and the SHA-256 of its content.
101
- function snapshot(directory) {
102
- const entries = new Map();
103
- const walk = (current) => {
104
- for (const entry of readdirSync(current, { withFileTypes: true })) {
105
- const path = join(current, entry.name);
106
- const rel = relative(directory, path).split("\\").join("/");
107
- if (entry.isSymbolicLink()) entries.set(rel, `link:${readlinkSync(path)}`);
108
- else if (entry.isDirectory()) { entries.set(`${rel}/`, "dir"); walk(path); }
109
- else {
110
- const executable = (statSync(path).mode & 0o111) ? "x" : "-";
111
- entries.set(rel, `${executable}:${createHash("sha256").update(readFileSync(path)).digest("hex")}`);
112
- }
113
- }
114
- };
115
- walk(directory);
116
- return entries;
117
- }
118
-
119
- function diffSnapshots(left, right, leftLabel, rightLabel) {
120
- const lines = [];
121
- for (const [path, value] of left) {
122
- if (!right.has(path)) lines.push(`only in ${leftLabel}: ${path}`);
123
- else if (right.get(path) !== value) lines.push(`differs: ${path}`);
124
- }
125
- for (const path of right.keys()) if (!left.has(path)) lines.push(`only in ${rightLabel}: ${path}`);
126
- return lines;
127
- }
128
-
129
- function assertSameTree(shellDir, installerDir) {
130
- const lines = diffSnapshots(snapshot(shellDir), snapshot(installerDir), "shell result", "installer result");
131
- assert(lines.length === 0, `${lines.length} difference(s) between the shell script and the installer:\n${lines.join("\n")}`);
132
- }
133
-
134
- function assertUnchanged(before, directory, what) {
135
- const lines = diffSnapshots(before, snapshot(directory), "before", "after");
136
- assert(lines.length === 0, `${what} changed the folder:\n${lines.join("\n")}`);
137
- }
138
-
139
- const read = (path) => readFileSync(path, "utf8");
140
- const lines = (path) => read(path).replace(/\r/g, "").split("\n");
141
-
142
- // Every file under .claude/commands/TechieFlow/<sub>/ must equal .tfcore/<sub>/ byte for byte.
143
- function mirrorDifferences(project) {
144
- const core = join(project, ".tfcore");
145
- const mirror = join(project, ".claude", "commands", "TechieFlow");
146
- const out = [];
147
- for (const sub of readdirSync(mirror)) {
148
- out.push(...diffSnapshots(snapshot(join(core, sub)), snapshot(join(mirror, sub)), `.tfcore/${sub}`, `mirror ${sub}`));
149
- }
150
- return out;
151
- }
152
-
153
- // Every {file:...} reference resolves relative to the folder holding the config file.
154
- function unresolvedReferences(configPath) {
155
- const text = read(configPath);
156
- const refs = [...new Set([...text.matchAll(/\{file:([^}]*)\}/g)].map((m) => m[1]))];
157
- return refs.filter((ref) => !existsSync(join(dirname(configPath), ref)));
158
- }
159
-
160
- // ---------------------------------------------------------------- fixtures
161
-
162
- // A small existing .NET project: enough for the brownfield checks, the build-output
163
- // ignore audit and the pre-commit hook (a .git/hooks folder is enough; no git is run).
164
- function seedProject(dir, { dotnet = true, gitDir = true, legacyPersona = true } = {}) {
165
- mkdirSync(dir, { recursive: true });
166
- if (dotnet) {
167
- mkdirSync(join(dir, "src", "App"), { recursive: true });
168
- writeFileSync(join(dir, "src", "App", "App.csproj"), '<Project Sdk="Microsoft.NET.Sdk.Web">\n</Project>\n');
169
- writeFileSync(join(dir, "src", "App", "Program.cs"), "// application code\n");
170
- }
171
- writeFileSync(join(dir, ".gitignore"), "# Project rules\n/dist/\n");
172
- mkdirSync(join(dir, "docs"), { recursive: true });
173
- writeFileSync(join(dir, "docs", "notes.md"), "project document\n");
174
- if (gitDir) mkdirSync(join(dir, ".git", "hooks"), { recursive: true });
175
- if (legacyPersona) {
176
- mkdirSync(join(dir, ".claude"), { recursive: true });
177
- writeFileSync(join(dir, ".claude", "trblazeui.md"), "# legacy NuGet persona\n");
178
- }
179
- }
180
-
181
- // Drift that update must repair, and owner content that update must leave alone.
182
- function perturbInstalledProject(dir) {
183
- appendFileSync(join(dir, ".tfcore", "tasks", "build-phase.md"), "\nlocal edit that update must undo\n");
184
- writeFileSync(join(dir, ".tfcore", "tasks", "stale-old-task.md"), "no longer ships\n");
185
- mkdirSync(join(dir, ".tfcore", "workflows"), { recursive: true });
186
- writeFileSync(join(dir, ".tfcore", "workflows", "old.yaml"), "stock cruft\n");
187
- mkdirSync(join(dir, ".tfcore", "mine"), { recursive: true });
188
- writeFileSync(join(dir, ".tfcore", "mine", "keep.md"), "owner content\n");
189
- appendFileSync(join(dir, ".tfcore", "core-config.yaml"), "customTechnicalDocuments: docs/MyApp-Architecture.md\n");
190
- appendFileSync(join(dir, ".tfcore", "routing.yaml"), "# owner tuned\n");
191
- const settings = join(dir, ".claude", "settings.json");
192
- writeFileSync(settings, read(settings).replace("{\n", '{\n "ownerKey": true,\n'));
193
- writeFileSync(join(dir, ".claude", "settings.local.json"), "{ \"local\": true }\n");
194
- writeFileSync(join(dir, ".claude", "commands", "trblazeui.md"), "# NuGet persona v2\n");
195
- rmSync(join(dir, ".claude", "commands", "TechieFlow", "tasks", "verify-phase.md"));
196
- writeFileSync(join(dir, ".claude", "commands", "TechieFlow", "tasks", "old.md"), "stale mirror file\n");
197
- mkdirSync(join(dir, ".opencode", "command"), { recursive: true });
198
- writeFileSync(join(dir, ".opencode", "command", "techierag.md"), "# NuGet persona\n");
199
- appendFileSync(join(dir, ".opencode", "plugin", "techieflow.js"), "\n// local edit\n");
200
- appendFileSync(join(dir, "WORKFLOW.html"), "<!-- local edit -->\n");
201
- appendFileSync(join(dir, "opencode.jsonc"), "// a comment only, no project keys\n");
202
- appendFileSync(join(dir, ".codex", "config.toml"), "\n# owner tuned\n");
203
- appendFileSync(join(dir, ".codex", "hooks.json"), "\n");
204
- writeFileSync(join(dir, "PROJECT-STATUS.md"), "# status\n");
205
- writeFileSync(join(dir, "CLAUDE.md"), "# claude\n");
206
- writeFileSync(join(dir, "docs", "MyApp-BRD.md"), "# BRD\n");
207
- }
208
-
209
- // The pre-2026-06 layout that update-framework.sh migrates in place.
210
- function seedLegacyProject(dir) {
211
- seedProject(dir, { legacyPersona: false });
212
- mkdirSync(join(dir, ".bmad-core", "tasks"), { recursive: true });
213
- mkdirSync(join(dir, ".bmad-core", "data"), { recursive: true });
214
- writeFileSync(join(dir, ".bmad-core", "core-config.yaml"), "slashPrefix: BMad\ndevDebugLog: .bmad-core/debug.md\ncustomTechnicalDocuments: docs/Mine.md\n");
215
- writeFileSync(join(dir, ".bmad-core", "tasks", "old.md"), "old task\n");
216
- writeFileSync(join(dir, ".bmad-core", "data", "bmad-kb.md"), "old kb\n");
217
- mkdirSync(join(dir, ".claude", "commands", "BMad"), { recursive: true });
218
- writeFileSync(join(dir, ".claude", "commands", "BMad", "x.md"), "old mirror\n");
219
- writeFileSync(join(dir, ".bmad-scaffold-note.txt"), "old note\n");
220
- writeFileSync(join(dir, "opencode.jsonc"), '{ "agent": { "bmad-master": { "prompt": "{file:./.bmad-core/agents/bmad-master.md}" } } }\n');
221
- writeFileSync(join(dir, "CLAUDE.md"), "See .bmad-core/ for the framework.\n");
222
- }
223
-
224
- // The clean copy of the framework: what `npm pack` ships, plus the three shell scripts.
225
- function stageCleanTemplate() {
226
- const packed = npm(["pack", "--json", `--pack-destination=${sandbox}`]);
227
- const tarball = join(sandbox, basename(JSON.parse(packed.stdout)[0].filename));
228
- const template = join(sandbox, "template");
229
- mkdirSync(template);
230
- run("tar", ["-xzf", tarball, "-C", template, "--strip-components=1"]);
231
- for (const script of ["scaffold-brownfield.sh", "scaffold-greenfield.sh", "update-framework.sh"]) {
232
- copyFileSync(join(root, script), join(template, script));
233
- chmodSync(join(template, script), 0o755);
234
- }
235
- return { template, tarball };
236
- }
237
-
238
- // ---------------------------------------------------------------- the checks
239
-
240
- try {
241
- const { template, tarball } = stageCleanTemplate();
242
- console.log(`clean template staged from ${basename(tarball)}`);
243
-
244
- // ---- brownfield: scaffold-brownfield.sh versus `install`
245
- const brownShell = join(sandbox, "brownfield-shell");
246
- const brownNpm = join(sandbox, "brownfield-installer");
247
- seedProject(brownShell);
248
- seedProject(brownNpm);
249
- shell(join(template, "scaffold-brownfield.sh"), [brownShell]);
250
- node([installer, "install", `--target=${brownNpm}`]);
251
-
252
- check("brownfield: installer result equals scaffold-brownfield.sh result", () => assertSameTree(brownShell, brownNpm));
253
- check("brownfield: no node_modules, package.json or lock file left in the target", () => {
254
- for (const path of ["node_modules", "package.json", "package-lock.json"]) assert(!existsSync(join(brownNpm, path)), `${path} was left behind`);
255
- });
256
- check("brownfield: the expected files are present", () => {
257
- for (const path of [
258
- ".tfcore/tasks/build-phase.md", ".tfcore/core-config.yaml", ".tfcore/routing.yaml",
259
- ".claude/commands/TechieFlow/agents/analyst.md", ".claude/commands/TechieFlow/tasks/build-phase.md",
260
- ".claude/commands/generate-html.md", ".claude/settings.json", ".claude/commands/trblazeui.md",
261
- ".opencode/plugin/techieflow.js", ".opencode/opencode.jsonc", "opencode.jsonc", "WORKFLOW.html",
262
- ".codex/config.toml", ".codex/hooks.json", ".codex/rules/techieflow.rules",
263
- ...(codexBinderRuns ? [".codex/agents/analyst.toml", ".agents/skills/techieflow-build/SKILL.md"] : []),
264
- ".tf-scaffold-note.txt",
265
- "docs/metrics/runs.jsonl", "docs/metrics/gates.jsonl", "docs/metrics/sessions.jsonl",
266
- "docs/metrics/commits.jsonl", "docs/metrics/misses.jsonl", "docs/metrics/README.md",
267
- ".git/hooks/pre-commit", ".gitattributes",
268
- ]) assert(existsSync(join(brownNpm, path)), `missing ${path}`);
269
- assert(!existsSync(join(brownNpm, ".claude/commands/techierag.md")), "a library persona was installed from the framework");
270
- assert(!existsSync(join(brownNpm, ".tfcore/.session")), "a local-only session folder was installed");
271
- assert(!existsSync(join(brownNpm, ".opencode/command/generate-html.md")), "install deployed a file only update deploys");
272
- });
273
- check("brownfield: project files are untouched", () => {
274
- assert(read(join(brownNpm, ".gitignore")).startsWith("# Project rules\n/dist/\n"), "project .gitignore rules were rewritten");
275
- assert(read(join(brownNpm, "docs/notes.md")) === "project document\n", "docs/ content changed");
276
- assert(read(join(brownNpm, "src/App/Program.cs")) === "// application code\n", "src/ content changed");
277
- assert(read(join(brownNpm, ".claude/trblazeui.md")) === "# legacy NuGet persona\n", "a NuGet-deployed persona changed");
278
- });
279
- check("brownfield: managed .gitignore entries present, docs/metrics not ignored", () => {
280
- const all = new Set(lines(join(brownNpm, ".gitignore")));
281
- for (const entry of [".tfcore/", ".claude/", ".opencode/", ".codex/", ".agents/skills/", "/CLAUDE.md", "/WORKFLOW.html", "/opencode.jsonc", "/.tf-scaffold-note.txt", "node_modules/", "/package.json", "/package-lock.json", "bin/", "obj/"]) {
282
- assert(all.has(entry), `.gitignore is missing ${entry}`);
283
- }
284
- assert(!all.has("docs/") && !all.has("docs/metrics/"), ".gitignore hides docs/metrics");
285
- });
286
- check("brownfield: Claude mirror is byte-identical to .tfcore/", () => {
287
- const diff = mirrorDifferences(brownNpm);
288
- assert(diff.length === 0, `${diff.length} mirror difference(s):\n${diff.join("\n")}`);
289
- });
290
- check("brownfield: every {file:} reference in both opencode.jsonc files resolves", () => {
291
- for (const file of ["opencode.jsonc", ".opencode/opencode.jsonc"]) {
292
- const bad = unresolvedReferences(join(brownNpm, file));
293
- assert(bad.length === 0, `${file} has unresolved references: ${bad.join(", ")}`);
294
- }
295
- assert(read(join(brownNpm, ".opencode/opencode.jsonc")).includes("{file:../.tfcore/"), ".opencode/opencode.jsonc does not point one folder up");
296
- });
297
- check("brownfield: a second install run changes nothing", () => {
298
- const before = snapshot(brownNpm);
299
- node([installer, "install", `--target=${brownNpm}`]);
300
- assertUnchanged(before, brownNpm, "the second install run");
301
- });
302
-
303
- // ---- greenfield: scaffold-greenfield.sh versus `install --greenfield`
304
- const greenShell = join(sandbox, "greenfield-shell");
305
- const greenNpm = join(sandbox, "greenfield-installer");
306
- shell(join(template, "scaffold-greenfield.sh"), [greenShell]);
307
- node([installer, "install", "--greenfield", `--target=${greenNpm}`]);
308
- check("greenfield: installer result equals scaffold-greenfield.sh result", () => assertSameTree(greenShell, greenNpm));
309
- check("greenfield: solution skeleton folders and greenfield note", () => {
310
- for (const path of ["src", "tests/playwright", "tests/unit"]) assert(existsSync(join(greenNpm, path)), `missing ${path}/`);
311
- assert(read(join(greenNpm, ".tf-scaffold-note.txt")).includes("greenfield"), "note is not the greenfield note");
312
- });
313
- check("greenfield: Claude mirror and OpenCode references", () => {
314
- const diff = mirrorDifferences(greenNpm);
315
- assert(diff.length === 0, `${diff.length} mirror difference(s):\n${diff.join("\n")}`);
316
- const bad = unresolvedReferences(join(greenNpm, ".opencode/opencode.jsonc"));
317
- assert(bad.length === 0, `unresolved references: ${bad.join(", ")}`);
318
- });
319
-
320
- // ---- update: update-framework.sh versus `update`, starting from one installed project
321
- const updateShell = join(sandbox, "update-shell");
322
- const updateNpm = join(sandbox, "update-installer");
323
- cpSync(brownNpm, updateShell, { recursive: true });
324
- cpSync(brownNpm, updateNpm, { recursive: true });
325
- perturbInstalledProject(updateShell);
326
- perturbInstalledProject(updateNpm);
327
- shell(join(template, "update-framework.sh"), [updateShell]);
328
- node([installer, "update", `--target=${updateNpm}`]);
329
- check("update: installer result equals update-framework.sh result", () => assertSameTree(updateShell, updateNpm));
330
- check("update: framework files refreshed", () => {
331
- assert(read(join(updateNpm, ".tfcore/tasks/build-phase.md")) === read(join(template, ".tfcore/tasks/build-phase.md")), "edited task not restored");
332
- assert(!existsSync(join(updateNpm, ".tfcore/tasks/stale-old-task.md")), "stale task not deleted");
333
- assert(!existsSync(join(updateNpm, ".tfcore/workflows")), "stale stock folder not removed");
334
- assert(existsSync(join(updateNpm, ".claude/commands/TechieFlow/tasks/verify-phase.md")), "deleted mirror file not restored");
335
- assert(!existsSync(join(updateNpm, ".claude/commands/TechieFlow/tasks/old.md")), "stale mirror file not deleted");
336
- assert(read(join(updateNpm, "WORKFLOW.html")) === read(join(template, "WORKFLOW.html")), "WORKFLOW.html not refreshed");
337
- assert(read(join(updateNpm, "opencode.jsonc")) === read(join(template, "opencode.jsonc")), "root opencode.jsonc with no project keys not refreshed");
338
- assert(existsSync(join(updateNpm, "opencode.jsonc.bak")), "old opencode.jsonc not backed up");
339
- assert(read(join(updateNpm, ".opencode/plugin/techieflow.js")) === read(join(template, ".opencode/plugin/techieflow.js")), "plugin not refreshed");
340
- assert(existsSync(join(updateNpm, ".opencode/command/generate-html.md")), "short-form OpenCode command not deployed");
341
- assert(read(join(updateNpm, ".codex/hooks.json")) === read(join(template, ".codex/hooks.json")), "Codex hooks not refreshed");
342
- assert(existsSync(join(updateNpm, ".claude/settings.json.bak")), "old settings.json not backed up");
343
- assert(!read(join(updateNpm, ".claude/settings.json")).includes("ownerKey"), "settings.json not refreshed");
344
- });
345
- check("update: owner content preserved", () => {
346
- assert(read(join(updateNpm, ".tfcore/core-config.yaml")).includes("customTechnicalDocuments: docs/MyApp-Architecture.md"), "core-config.yaml lost the owner's line");
347
- assert(read(join(updateNpm, ".tfcore/routing.yaml")).includes("# owner tuned"), "routing.yaml was replaced");
348
- assert(existsSync(join(updateNpm, ".tfcore/mine/keep.md")), "an unknown .tfcore folder was deleted");
349
- assert(read(join(updateNpm, ".claude/settings.local.json")) === "{ \"local\": true }\n", "settings.local.json touched");
350
- assert(read(join(updateNpm, ".claude/commands/trblazeui.md")) === "# NuGet persona v2\n", "NuGet persona under .claude/commands overwritten");
351
- assert(read(join(updateNpm, ".opencode/command/techierag.md")) === "# NuGet persona\n", "NuGet persona under .opencode/command overwritten");
352
- assert(read(join(updateNpm, ".codex/config.toml")).includes("# owner tuned"), ".codex/config.toml replaced");
353
- for (const path of ["PROJECT-STATUS.md", "CLAUDE.md", "docs/MyApp-BRD.md", "docs/notes.md", "src/App/Program.cs"]) {
354
- assert(existsSync(join(updateNpm, path)), `${path} missing after update`);
355
- }
356
- });
357
- check("update: Claude mirror and OpenCode references after update", () => {
358
- const diff = mirrorDifferences(updateNpm);
359
- assert(diff.length === 0, `${diff.length} mirror difference(s):\n${diff.join("\n")}`);
360
- for (const file of ["opencode.jsonc", ".opencode/opencode.jsonc"]) {
361
- const bad = unresolvedReferences(join(updateNpm, file));
362
- assert(bad.length === 0, `${file} has unresolved references: ${bad.join(", ")}`);
363
- }
364
- });
365
-
366
- // ---- update on a project that is already current: nothing to back up
367
- const currentShell = join(sandbox, "update-current-shell");
368
- const currentNpm = join(sandbox, "update-current-installer");
369
- cpSync(brownNpm, currentShell, { recursive: true });
370
- cpSync(brownNpm, currentNpm, { recursive: true });
371
- shell(join(template, "update-framework.sh"), [currentShell]);
372
- node([installer, "update", `--target=${currentNpm}`]);
373
- check("update on a current project: same result as the shell script, no backups made", () => {
374
- assertSameTree(currentShell, currentNpm);
375
- assert(!existsSync(join(currentNpm, ".claude/settings.json.bak")), "settings.json was backed up although it was current");
376
- assert(!existsSync(join(currentNpm, "opencode.jsonc.bak")), "opencode.jsonc was backed up although it was current");
377
- });
378
-
379
- // ---- update --dry-run writes nothing (both routes)
380
- const dryShell = join(sandbox, "update-dry-shell");
381
- const dryNpm = join(sandbox, "update-dry-installer");
382
- cpSync(brownNpm, dryShell, { recursive: true });
383
- cpSync(brownNpm, dryNpm, { recursive: true });
384
- perturbInstalledProject(dryShell);
385
- perturbInstalledProject(dryNpm);
386
- check("update --dry-run: neither route changes a file", () => {
387
- const beforeShell = snapshot(dryShell);
388
- const beforeNpm = snapshot(dryNpm);
389
- shell(join(template, "update-framework.sh"), [dryShell, "--dry-run"]);
390
- node([installer, "update", "--dry-run", `--target=${dryNpm}`]);
391
- assertUnchanged(beforeShell, dryShell, "update-framework.sh --dry-run");
392
- assertUnchanged(beforeNpm, dryNpm, "update --dry-run");
393
- });
394
-
395
- // ---- update --keep-permissions leaves an edited settings.json alone
396
- const keepShell = join(sandbox, "update-keep-shell");
397
- const keepNpm = join(sandbox, "update-keep-installer");
398
- cpSync(brownNpm, keepShell, { recursive: true });
399
- cpSync(brownNpm, keepNpm, { recursive: true });
400
- perturbInstalledProject(keepShell);
401
- perturbInstalledProject(keepNpm);
402
- shell(join(template, "update-framework.sh"), [keepShell, "--keep-permissions"]);
403
- node([installer, "update", "--keep-permissions", `--target=${keepNpm}`]);
404
- check("update --keep-permissions: same result as the shell script, settings.json kept", () => {
405
- assertSameTree(keepShell, keepNpm);
406
- assert(read(join(keepNpm, ".claude/settings.json")).includes("ownerKey"), "settings.json was refreshed despite --keep-permissions");
407
- assert(!existsSync(join(keepNpm, ".claude/settings.json.bak")), "a backup was made despite --keep-permissions");
408
- });
409
-
410
- // ---- update on the old .bmad-core layout
411
- const legacyShell = join(sandbox, "legacy-shell");
412
- const legacyNpm = join(sandbox, "legacy-installer");
413
- seedLegacyProject(legacyShell);
414
- seedLegacyProject(legacyNpm);
415
- shell(join(template, "update-framework.sh"), [legacyShell]);
416
- node([installer, "update", `--target=${legacyNpm}`]);
417
- check("update of the old .bmad-core layout: same result as the shell script", () => {
418
- assertSameTree(legacyShell, legacyNpm);
419
- assert(!existsSync(join(legacyNpm, ".bmad-core")), ".bmad-core was not renamed");
420
- assert(read(join(legacyNpm, ".tfcore/core-config.yaml")).includes("slashPrefix: TechieFlow"), "core-config.yaml not patched");
421
- assert(read(join(legacyNpm, ".tfcore/core-config.yaml")).includes("customTechnicalDocuments: docs/Mine.md"), "core-config.yaml owner line lost");
422
- assert(!existsSync(join(legacyNpm, ".claude/commands/BMad")), "old mirror not removed");
423
- assert(existsSync(join(legacyNpm, "opencode.jsonc.bak")), "legacy opencode.jsonc not backed up");
424
- });
425
-
426
- // ---- uninstall: preview first, then --force
427
- const uninstallDir = join(sandbox, "uninstall");
428
- cpSync(brownNpm, uninstallDir, { recursive: true });
429
- check("uninstall without --force removes nothing", () => {
430
- const before = snapshot(uninstallDir);
431
- node([installer, "uninstall", `--target=${uninstallDir}`]);
432
- assertUnchanged(before, uninstallDir, "uninstall without --force");
433
- });
434
- check("uninstall --force removes the framework and keeps the project", () => {
435
- node([installer, "uninstall", "--force", `--target=${uninstallDir}`]);
436
- for (const path of [".tfcore", ".claude/commands/TechieFlow", ".claude/commands/generate-html.md", ".claude/settings.json", ".opencode", ".codex", ".agents", "WORKFLOW.html", "opencode.jsonc", ".tf-scaffold-note.txt", ".git/hooks/pre-commit"]) {
437
- assert(!existsSync(join(uninstallDir, path)), `${path} still present after uninstall`);
438
- }
439
- for (const path of ["docs/notes.md", "docs/metrics/runs.jsonl", "docs/metrics/README.md", "src/App/App.csproj", ".claude/trblazeui.md", ".claude/commands/trblazeui.md", ".gitattributes"]) {
440
- assert(existsSync(join(uninstallDir, path)), `${path} was removed by uninstall`);
441
- }
442
- const ignore = read(join(uninstallDir, ".gitignore"));
443
- assert(ignore.startsWith("# Project rules\n/dist/\n"), "project .gitignore rules changed");
444
- const all = new Set(ignore.replace(/\r/g, "").split("\n"));
445
- for (const entry of [".tfcore/", ".claude/", "/WORKFLOW.html", "/opencode.jsonc"]) assert(!all.has(entry), `.gitignore still lists ${entry}`);
446
- assert(!ignore.includes("deployed copies, never commit"), ".gitignore still carries the framework block header");
447
- assert(all.has("node_modules/") && all.has("bin/"), "uninstall removed ignore rules that describe the project's own build output");
448
- });
449
-
450
- // ---- install --dry-run and --no-gitignore
451
- check("install --greenfield --dry-run creates nothing", () => {
452
- const dryTarget = join(sandbox, "dry-greenfield");
453
- node([installer, "install", "--greenfield", "--dry-run", `--target=${dryTarget}`]);
454
- assert(!existsSync(dryTarget), "dry run created the target folder");
455
- });
456
- check("install --dry-run on an existing project changes nothing", () => {
457
- const dryTarget = join(sandbox, "dry-brownfield");
458
- seedProject(dryTarget);
459
- const before = snapshot(dryTarget);
460
- node([installer, "install", "--dry-run", `--target=${dryTarget}`]);
461
- assertUnchanged(before, dryTarget, "install --dry-run");
462
- });
463
- check("install --no-gitignore leaves .gitignore and .gitattributes alone", () => {
464
- const target = join(sandbox, "no-gitignore");
465
- seedProject(target);
466
- node([installer, "install", "--no-gitignore", `--target=${target}`]);
467
- assert(read(join(target, ".gitignore")) === "# Project rules\n/dist/\n", ".gitignore was edited");
468
- assert(!existsSync(join(target, ".gitattributes")), ".gitattributes was created");
469
- assert(existsSync(join(target, ".tfcore/tasks/build-phase.md")), "framework was not installed");
470
- });
471
- check("install refuses the framework's own folder", () => {
472
- const result = node([installer, "install", `--target=${root}`], { allowFailure: true });
473
- assert(result.status !== 0, "installing into the framework repository was not refused");
474
- });
475
- check("update refuses a folder that has no framework", () => {
476
- const target = join(sandbox, "not-scaffolded");
477
- seedProject(target);
478
- const result = node([installer, "update", `--target=${target}`], { allowFailure: true });
479
- assert(result.status !== 0, "update did not refuse a folder without .tfcore/");
480
- });
481
-
482
- // ---- the one-shot form: what `npx @techierathore/techieflow@latest install` runs
483
- const npxTarget = join(sandbox, "npx-install");
484
- seedProject(npxTarget);
485
- check("one-shot `npm exec` install from the packed tarball matches the installer", () => {
486
- npm(["exec", "--yes", `--package=${tarball}`, "--", "techieflow", "install"], { cwd: npxTarget });
487
- assertSameTree(brownNpm, npxTarget);
488
- for (const path of ["node_modules", "package.json", "package-lock.json"]) assert(!existsSync(join(npxTarget, path)), `${path} left behind by npm exec`);
489
- });
490
- } catch (error) {
491
- failures.push(`setup: ${error.message}`);
492
- console.log(`FAIL setup\n ${error.message.split("\n").join("\n ")}`);
493
- } finally {
494
- if (keepSandbox) console.log(`temporary folder kept: ${sandbox}`);
495
- else rmSync(sandbox, { recursive: true, force: true });
496
- }
497
-
498
- console.log("");
499
- for (const note of notes) console.log(`note: ${note}`);
500
- if (failures.length) {
501
- console.log(`${passed} check(s) passed, ${failures.length} failed:`);
502
- for (const failure of failures) console.log(` - ${failure}`);
503
- process.exit(1);
504
- }
505
- console.log(`installer tests passed (${passed} checks)`);
@@ -1,155 +0,0 @@
1
- #!/usr/bin/env node
2
- // scripts/validate.mjs — the checks that run before every publish and on every push.
3
- //
4
- // 1. Mirror parity: every file under .claude/commands/TechieFlow/<sub>/ is byte-identical
5
- // to .tfcore/<sub>/. Claude Code only reads the mirror, so drift means Claude Code and
6
- // OpenCode run different instructions.
7
- // 2. OpenCode references: every {file:...} in opencode.jsonc points at a file that exists.
8
- // One dead reference makes OpenCode reject the whole config.
9
- // 3. Shell syntax: `bash -n` on every .sh file in the repository.
10
- // 4. Package contents: `npm pack --dry-run` succeeds, ships every file the shell scripts
11
- // deploy, and ships none of the local-only or per-machine files.
12
- //
13
- // Exit code 0 means every check passed. Any failure exits 1 and names the problem.
14
-
15
- import { spawnSync } from "node:child_process";
16
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
17
- import { dirname, join, relative, resolve } from "node:path";
18
- import { fileURLToPath } from "node:url";
19
-
20
- const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
21
- const problems = [];
22
- let passed = 0;
23
-
24
- function check(name, fn) {
25
- try {
26
- fn();
27
- passed++;
28
- console.log(`ok ${name}`);
29
- } catch (error) {
30
- problems.push(`${name}: ${error.message}`);
31
- console.log(`FAIL ${name}\n ${error.message.split("\n").join("\n ")}`);
32
- }
33
- }
34
-
35
- function assert(condition, message) {
36
- if (!condition) throw new Error(message);
37
- }
38
-
39
- const skipDirectories = new Set([".git", "node_modules"]);
40
-
41
- function filesUnder(directory) {
42
- const out = [];
43
- const walk = (current) => {
44
- for (const entry of readdirSync(current, { withFileTypes: true })) {
45
- if (entry.isDirectory()) { if (!skipDirectories.has(entry.name)) walk(join(current, entry.name)); }
46
- else if (entry.isFile()) out.push(join(current, entry.name));
47
- }
48
- };
49
- if (existsSync(directory)) walk(directory);
50
- return out;
51
- }
52
-
53
- const rel = (path) => relative(root, path).split("\\").join("/");
54
-
55
- // ---- 1. mirror parity
56
- check("Claude mirror is byte-identical to .tfcore/", () => {
57
- const mirror = join(root, ".claude", "commands", "TechieFlow");
58
- const differences = [];
59
- for (const sub of readdirSync(mirror)) {
60
- const coreFiles = new Map(filesUnder(join(root, ".tfcore", sub)).map((f) => [relative(join(root, ".tfcore", sub), f), f]));
61
- const mirrorFiles = new Map(filesUnder(join(mirror, sub)).map((f) => [relative(join(mirror, sub), f), f]));
62
- for (const [name, corePath] of coreFiles) {
63
- if (!mirrorFiles.has(name)) differences.push(`missing from mirror: ${sub}/${name}`);
64
- else if (!readFileSync(corePath).equals(readFileSync(mirrorFiles.get(name)))) differences.push(`content differs: ${sub}/${name}`);
65
- }
66
- for (const name of mirrorFiles.keys()) if (!coreFiles.has(name)) differences.push(`only in mirror: ${sub}/${name}`);
67
- }
68
- assert(differences.length === 0, `${differences.length} difference(s) between .tfcore/ and .claude/commands/TechieFlow/:\n${differences.join("\n")}\nFix: copy the changed files from .tfcore/ over the mirror, or re-run the scaffold's agent sync for every folder.`);
69
- });
70
-
71
- // ---- 2. OpenCode references
72
- check("every {file:} reference in opencode.jsonc resolves", () => {
73
- const text = readFileSync(join(root, "opencode.jsonc"), "utf8");
74
- const refs = [...new Set([...text.matchAll(/\{file:([^}]*)\}/g)].map((m) => m[1]))];
75
- assert(refs.length > 0, "opencode.jsonc contains no {file:} references at all");
76
- const dead = refs.filter((ref) => !existsSync(join(root, ref)));
77
- assert(dead.length === 0, `dead references:\n${dead.join("\n")}`);
78
- });
79
-
80
- // ---- 3. shell syntax
81
- check("bash -n passes on every .sh file", () => {
82
- const scripts = filesUnder(root).filter((f) => f.endsWith(".sh"));
83
- assert(scripts.length > 0, "no .sh files found");
84
- const broken = [];
85
- for (const script of scripts) {
86
- const result = spawnSync("bash", ["-n", script], { encoding: "utf8" });
87
- if (result.error) throw new Error(`bash could not be started: ${result.error.message}`);
88
- if (result.status !== 0) broken.push(`${rel(script)}: ${result.stderr.trim()}`);
89
- }
90
- assert(broken.length === 0, broken.join("\n"));
91
- console.log(` ${scripts.length} script(s) checked`);
92
- });
93
-
94
- // ---- 4. package contents
95
- const libraryPersonas = new Set(["trblazeui.md", "techierag.md"]);
96
-
97
- // What the three shell scripts copy out of this repository into a project.
98
- function deployedFiles() {
99
- const out = [];
100
- for (const f of filesUnder(join(root, ".tfcore"))) {
101
- const r = rel(f);
102
- if (!r.startsWith(".tfcore/.session/")) out.push(r);
103
- }
104
- for (const f of filesUnder(join(root, ".claude", "commands"))) {
105
- if (!libraryPersonas.has(f.split("/").pop())) out.push(rel(f));
106
- }
107
- for (const f of filesUnder(join(root, ".opencode", "plugin"))) if (f.endsWith(".js")) out.push(rel(f));
108
- for (const f of filesUnder(join(root, ".opencode", "command"))) {
109
- if (f.endsWith(".md") && !libraryPersonas.has(f.split("/").pop())) out.push(rel(f));
110
- }
111
- out.push(".codex/config.toml", ".codex/hooks.json");
112
- for (const f of filesUnder(join(root, ".codex", "rules"))) out.push(rel(f));
113
- out.push("opencode.jsonc", "WORKFLOW.html");
114
- return out.filter((r) => !/(^|\/)(\.DS_Store|Thumbs\.db|desktop\.ini)$/.test(r) && !r.endsWith(".bak"));
115
- }
116
-
117
- const mustNotShip = [
118
- ".tfcore/.session", ".claude/settings.json", ".claude/settings.local.json",
119
- ".claude/trblazeui.md", ".claude/techierag.md",
120
- ".claude/commands/trblazeui.md", ".claude/commands/techierag.md",
121
- ".opencode/command/trblazeui.md", ".opencode/command/techierag.md",
122
- ".opencode/node_modules", ".opencode/package.json", ".opencode/package-lock.json", ".opencode/.gitignore",
123
- ".codex/agents", ".agents", ".techierag", ".trblazeui", ".github",
124
- "scaffold-brownfield.sh", "scaffold-greenfield.sh", "update-framework.sh",
125
- "docs/TechieFlow-Requirements.md", "docs/TechieFlow-How-It-Works.md", "docs/metrics",
126
- "DECISIONS.md", "WorkFlow-Context.md", "CodexChanges.md",
127
- ];
128
- const mustShip = [
129
- "package.json", "LICENSE", "README.md", "docs/TechieFlow-Installation.md",
130
- "scripts/install.mjs", "scripts/test-install.mjs", "scripts/validate.mjs",
131
- ];
132
-
133
- check("npm pack --dry-run succeeds and ships the right files", () => {
134
- const onWindows = process.platform === "win32";
135
- const result = process.env.npm_execpath
136
- ? spawnSync(process.execPath, [process.env.npm_execpath, "pack", "--dry-run", "--json"], { cwd: root, encoding: "utf8" })
137
- : spawnSync(onWindows ? "npm.cmd" : "npm", ["pack", "--dry-run", "--json"], { cwd: root, encoding: "utf8", shell: onWindows });
138
- if (result.error) throw new Error(`npm could not be started: ${result.error.message}`);
139
- assert(result.status === 0, `npm pack --dry-run failed:\n${result.stdout}${result.stderr}`);
140
- const packed = new Set(JSON.parse(result.stdout)[0].files.map((f) => f.path));
141
- const missing = [...deployedFiles(), ...mustShip].filter((f) => !packed.has(f));
142
- const leaked = [...packed].filter((p) => mustNotShip.some((bad) => p === bad || p.startsWith(`${bad}/`)));
143
- const report = [];
144
- if (missing.length) report.push(`files the shell scripts deploy but the package does not ship:\n${missing.join("\n")}`);
145
- if (leaked.length) report.push(`files that must not ship:\n${leaked.join("\n")}`);
146
- assert(report.length === 0, report.join("\n"));
147
- console.log(` ${packed.size} file(s) in the package`);
148
- });
149
-
150
- console.log("");
151
- if (problems.length) {
152
- console.log(`${passed} check(s) passed, ${problems.length} failed`);
153
- process.exit(1);
154
- }
155
- console.log(`validation passed (${passed} checks)`);