@techierathore/techieflow 1.0.1 → 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.
- package/docs/TechieFlow-Installation.md +26 -2
- package/package.json +4 -3
- package/scripts/install.mjs +21 -0
- package/scripts/npm-cleanup.mjs +137 -0
- package/scripts/npm-postinstall.mjs +49 -0
- package/scripts/test-install.mjs +0 -505
- package/scripts/validate.mjs +0 -163
|
@@ -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
|
-
|
|
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
|
-
##
|
|
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.
|
|
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/
|
|
43
|
-
"scripts/
|
|
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",
|
package/scripts/install.mjs
CHANGED
|
@@ -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.");
|
package/scripts/test-install.mjs
DELETED
|
@@ -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)`);
|
package/scripts/validate.mjs
DELETED
|
@@ -1,163 +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 version = spawnSync("bash", ["-c", "echo $BASH_VERSION"], { encoding: "utf8" });
|
|
85
|
-
if (version.error) throw new Error(`bash could not be started: ${version.error.message}`);
|
|
86
|
-
const bashVersion = version.stdout.trim();
|
|
87
|
-
const broken = [];
|
|
88
|
-
for (const script of scripts) {
|
|
89
|
-
const result = spawnSync("bash", ["-n", script], { encoding: "utf8" });
|
|
90
|
-
if (result.error) throw new Error(`bash could not be started: ${result.error.message}`);
|
|
91
|
-
if (result.status !== 0) broken.push(`${rel(script)}: ${result.stderr.trim()}`);
|
|
92
|
-
}
|
|
93
|
-
if (broken.length && /^[123]\./.test(bashVersion)) {
|
|
94
|
-
broken.push(`This terminal's bash is ${bashVersion}, Apple's old copy. The framework needs bash 4 or newer.`,
|
|
95
|
-
"On a Mac: install it with `brew install bash`, put /opt/homebrew/bin first on the PATH in ~/.zshrc,",
|
|
96
|
-
"then open a NEW terminal window and run the command again. Check with: which -a bash");
|
|
97
|
-
}
|
|
98
|
-
assert(broken.length === 0, broken.join("\n"));
|
|
99
|
-
console.log(` ${scripts.length} script(s) checked with bash ${bashVersion}`);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// ---- 4. package contents
|
|
103
|
-
const libraryPersonas = new Set(["trblazeui.md", "techierag.md"]);
|
|
104
|
-
|
|
105
|
-
// What the three shell scripts copy out of this repository into a project.
|
|
106
|
-
function deployedFiles() {
|
|
107
|
-
const out = [];
|
|
108
|
-
for (const f of filesUnder(join(root, ".tfcore"))) {
|
|
109
|
-
const r = rel(f);
|
|
110
|
-
if (!r.startsWith(".tfcore/.session/")) out.push(r);
|
|
111
|
-
}
|
|
112
|
-
for (const f of filesUnder(join(root, ".claude", "commands"))) {
|
|
113
|
-
if (!libraryPersonas.has(f.split("/").pop())) out.push(rel(f));
|
|
114
|
-
}
|
|
115
|
-
for (const f of filesUnder(join(root, ".opencode", "plugin"))) if (f.endsWith(".js")) out.push(rel(f));
|
|
116
|
-
for (const f of filesUnder(join(root, ".opencode", "command"))) {
|
|
117
|
-
if (f.endsWith(".md") && !libraryPersonas.has(f.split("/").pop())) out.push(rel(f));
|
|
118
|
-
}
|
|
119
|
-
out.push(".codex/config.toml", ".codex/hooks.json");
|
|
120
|
-
for (const f of filesUnder(join(root, ".codex", "rules"))) out.push(rel(f));
|
|
121
|
-
out.push("opencode.jsonc", "WORKFLOW.html");
|
|
122
|
-
return out.filter((r) => !/(^|\/)(\.DS_Store|Thumbs\.db|desktop\.ini)$/.test(r) && !r.endsWith(".bak"));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const mustNotShip = [
|
|
126
|
-
".tfcore/.session", ".claude/settings.json", ".claude/settings.local.json",
|
|
127
|
-
".claude/trblazeui.md", ".claude/techierag.md",
|
|
128
|
-
".claude/commands/trblazeui.md", ".claude/commands/techierag.md",
|
|
129
|
-
".opencode/command/trblazeui.md", ".opencode/command/techierag.md",
|
|
130
|
-
".opencode/node_modules", ".opencode/package.json", ".opencode/package-lock.json", ".opencode/.gitignore",
|
|
131
|
-
".codex/agents", ".agents", ".techierag", ".trblazeui", ".github",
|
|
132
|
-
"scaffold-brownfield.sh", "scaffold-greenfield.sh", "update-framework.sh",
|
|
133
|
-
"docs/TechieFlow-Requirements.md", "docs/TechieFlow-How-It-Works.md", "docs/metrics",
|
|
134
|
-
"DECISIONS.md", "WorkFlow-Context.md", "CodexChanges.md",
|
|
135
|
-
];
|
|
136
|
-
const mustShip = [
|
|
137
|
-
"package.json", "LICENSE", "README.md", "docs/TechieFlow-Installation.md",
|
|
138
|
-
"scripts/install.mjs", "scripts/test-install.mjs", "scripts/validate.mjs",
|
|
139
|
-
];
|
|
140
|
-
|
|
141
|
-
check("npm pack --dry-run succeeds and ships the right files", () => {
|
|
142
|
-
const onWindows = process.platform === "win32";
|
|
143
|
-
const result = process.env.npm_execpath
|
|
144
|
-
? spawnSync(process.execPath, [process.env.npm_execpath, "pack", "--dry-run", "--json"], { cwd: root, encoding: "utf8" })
|
|
145
|
-
: spawnSync(onWindows ? "npm.cmd" : "npm", ["pack", "--dry-run", "--json"], { cwd: root, encoding: "utf8", shell: onWindows });
|
|
146
|
-
if (result.error) throw new Error(`npm could not be started: ${result.error.message}`);
|
|
147
|
-
assert(result.status === 0, `npm pack --dry-run failed:\n${result.stdout}${result.stderr}`);
|
|
148
|
-
const packed = new Set(JSON.parse(result.stdout)[0].files.map((f) => f.path));
|
|
149
|
-
const missing = [...deployedFiles(), ...mustShip].filter((f) => !packed.has(f));
|
|
150
|
-
const leaked = [...packed].filter((p) => mustNotShip.some((bad) => p === bad || p.startsWith(`${bad}/`)));
|
|
151
|
-
const report = [];
|
|
152
|
-
if (missing.length) report.push(`files the shell scripts deploy but the package does not ship:\n${missing.join("\n")}`);
|
|
153
|
-
if (leaked.length) report.push(`files that must not ship:\n${leaked.join("\n")}`);
|
|
154
|
-
assert(report.length === 0, report.join("\n"));
|
|
155
|
-
console.log(` ${packed.size} file(s) in the package`);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
console.log("");
|
|
159
|
-
if (problems.length) {
|
|
160
|
-
console.log(`${passed} check(s) passed, ${problems.length} failed`);
|
|
161
|
-
process.exit(1);
|
|
162
|
-
}
|
|
163
|
-
console.log(`validation passed (${passed} checks)`);
|