@tech-leads-club/harness-toolkit 0.4.0 → 0.4.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/bin/tlc-cli.ts CHANGED
@@ -9,18 +9,23 @@ import {
9
9
  rmSync,
10
10
  writeFileSync,
11
11
  } from "node:fs";
12
- import { delimiter, join } from "node:path";
12
+ import { join } from "node:path";
13
13
  import { coreFacade } from "../src/core/index.ts";
14
14
  import { emitJson, JSON_FLAG, takeJsonFlag, unknownFlags } from "../src/platform/cli-output.ts";
15
15
  import { linkDir, linkFile, seedConfig } from "../src/platform/links.ts";
16
16
  import {
17
+ EXECUTABLE_EXTENSIONS,
18
+ executableOnPath,
19
+ findProjectRoot,
17
20
  flagsDir,
18
21
  isOnPath,
19
22
  launcherBinDir,
23
+ machineHome,
20
24
  projectConfigPath,
21
25
  projectStateDir,
22
26
  providerConfigDirs,
23
27
  runtimeHome,
28
+ sameLocation,
24
29
  } from "../src/platform/paths.ts";
25
30
  import { type Row, render, type Screen, type Section } from "../src/platform/screen.ts";
26
31
  import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
@@ -31,8 +36,17 @@ export class UsageError extends Error {}
31
36
  // single door into core and the two cannot drift apart.
32
37
  type Posture = ReturnType<typeof coreFacade.policy.resolveProjectPosture>;
33
38
 
39
+ /**
40
+ * invariant: an explicit `TLC_PROJECT_DIR` still wins — the hooks set it from the host's own payload, which knows
41
+ * the workspace better than a directory walk can. Everything else discovers the project the way `git` does
42
+ * ([/decisions/ad-101.md](/decisions/ad-101.md)).
43
+ */
34
44
  export function resolveProjectRoot(): string {
35
- return process.env.TLC_PROJECT_DIR ?? process.cwd();
45
+ const declared = process.env.TLC_PROJECT_DIR;
46
+ if (declared) {
47
+ return declared;
48
+ }
49
+ return findProjectRoot(process.cwd()) ?? process.cwd();
36
50
  }
37
51
 
38
52
  export function modeFilePath(root: string): string {
@@ -386,9 +400,18 @@ export function acceptPolicy(root: string, paths: string[], interactive: boolean
386
400
  const notHere = requested.filter((path) => !blocked.includes(path));
387
401
  const outcome = coreFacade.policy.acceptPolicySources(root, requested);
388
402
  if (outcome.kind === "not-a-source") {
403
+ /**
404
+ * hazard: this listed the sources and never said which project it had resolved. Run from a home directory,
405
+ * `projectConfigPath(root)` *is* the machine config path — so the list showed the same file twice, none of the
406
+ * repository's own paths, and no hint that the root was wrong. An operator read it as a defect in the product
407
+ * and lost the afternoon to it ([/decisions/ad-101.md](/decisions/ad-101.md)).
408
+ *
409
+ * invariant: the success path already names the project. The failure path is the one that needed it.
410
+ */
389
411
  throw new UsageError(
390
412
  [
391
413
  `not a policy source: ${outcome.paths.join(", ")}`,
414
+ `project: ${root} — pass TLC_PROJECT_DIR or run this from the repository whose session is blocked`,
392
415
  "The sources the loader reads are:",
393
416
  ...outcome.sources.map((source) => ` ${source}`),
394
417
  ].join("\n"),
@@ -760,24 +783,16 @@ const GATE_FIELDS: Record<string, GateField> = {
760
783
  * name is first, so a POSIX `foo` is never beaten by a stray `foo.exe`
761
784
  * ([/decisions/ad-097.md](/decisions/ad-097.md)).
762
785
  */
763
- const EXECUTABLE_EXTENSIONS = ["", ".exe", ".cmd", ".bat", ".ps1"];
764
-
786
+ /**
787
+ * why this still exists beside `executableOnPath`: it also answers for a name that is already a path, which a PATH
788
+ * walk has nothing to say about. The walk itself is not repeated here
789
+ * ([/decisions/ad-101.md](/decisions/ad-101.md)).
790
+ */
765
791
  export function resolveExecutable(name: string, env: NodeJS.ProcessEnv = process.env): string | null {
766
- const candidates = (base: string): string[] => EXECUTABLE_EXTENSIONS.map((ext) => `${base}${ext}`);
767
-
768
792
  if (name.includes("/") || name.includes("\\")) {
769
- return candidates(name).find((candidate) => existsSync(candidate)) ?? null;
793
+ return EXECUTABLE_EXTENSIONS.map((ext) => `${name}${ext}`).find((c) => existsSync(c)) ?? null;
770
794
  }
771
- for (const dir of (env.PATH ?? "").split(delimiter)) {
772
- if (!dir) {
773
- continue;
774
- }
775
- const found = candidates(join(dir, name)).find((candidate) => existsSync(candidate));
776
- if (found) {
777
- return found;
778
- }
779
- }
780
- return null;
795
+ return executableOnPath(name, env);
781
796
  }
782
797
 
783
798
  /**
@@ -897,6 +912,29 @@ export function pricesHelpText(style: Style = PLAIN): string {
897
912
  return render(pricesHelpScreen(), style);
898
913
  }
899
914
 
915
+ /**
916
+ * The installed version, read from the runtime's own manifest.
917
+ *
918
+ * hazard: nothing showed it. `doctor` printed twenty rows and not one carried a version, and `update` on the npm
919
+ * route said `runtime → <path>` without naming what it was on or what it moved to
920
+ * ([/decisions/ad-101.md](/decisions/ad-101.md)).
921
+ */
922
+ export function runtimeVersion(home: string): string | null {
923
+ try {
924
+ const raw = JSON.parse(readFileSync(join(home, "package.json"), "utf8")) as { version?: unknown };
925
+ return typeof raw.version === "string" ? raw.version : null;
926
+ } catch {
927
+ return null;
928
+ }
929
+ }
930
+
931
+ /** why one line: an unchanged version is the common case and reads better as a sentence than as two rows. */
932
+ export function versionMoveLine(before: string | null, after: string | null): string {
933
+ return before !== null && after !== null && before !== after
934
+ ? `update: ${before} → ${after}`
935
+ : `update: already at ${after ?? before ?? "unknown"}`;
936
+ }
937
+
900
938
  export function resolveHarnessRoot(): string {
901
939
  const home = runtimeHome();
902
940
  try {
@@ -995,6 +1033,18 @@ export function npmRootFailureMessage(home: string): string {
995
1033
  * then reports it healthy while the command still does not exist.
996
1034
  */
997
1035
  export function launcherLines(dest: string): string[] {
1036
+ /**
1037
+ * hazard: this linked unconditionally. An install to a throwaway `TLC_INSTALL_DEST` therefore pointed the
1038
+ * machine's `tlc` at that directory — measured: a proof-of-concept install into a temp directory left the
1039
+ * operator's command running from `/tmp`, and every `tlc harness ...` after it resolved its runtime there
1040
+ * ([/decisions/ad-101.md](/decisions/ad-101.md)).
1041
+ *
1042
+ * invariant: only the machine's own runtime home owns the command on `PATH`. Installing somewhere else is a
1043
+ * deliberate act and must not reach into anybody's shell.
1044
+ */
1045
+ if (!sameLocation(dest, machineHome())) {
1046
+ return [`tlc not linked — ${dest} is not this machine's runtime home`];
1047
+ }
998
1048
  const dir = launcherBinDir();
999
1049
  const source = join(dest, "bin", "tlc");
1000
1050
  // hazard: `symlinkSync` happily creates a link to a path that is not there, and `existsSync` on a dangling link
@@ -1369,7 +1419,8 @@ function runUpdate(root: string): never {
1369
1419
  const dest = resolveHarnessRoot();
1370
1420
  const revisionBefore = runtimeRevision(dest).revision;
1371
1421
  const home = runtimeHome();
1372
- console.log(`update: runtime ${dest}`);
1422
+ const versionBefore = runtimeVersion(dest);
1423
+ console.log(`update: runtime → ${dest} (${versionBefore ?? "unknown"})`);
1373
1424
 
1374
1425
  if (!existsSync(join(dest, "bin", "tlc-exec.mjs"))) {
1375
1426
  console.error(`update: missing install at ${home}`);
@@ -1414,6 +1465,7 @@ function runUpdate(root: string): never {
1414
1465
  if ((sync.status ?? 1) !== 0) {
1415
1466
  process.exit(sync.status ?? 1);
1416
1467
  }
1468
+ console.log(versionMoveLine(versionBefore, runtimeVersion(dest)));
1417
1469
  } else if (kind === "unmanaged") {
1418
1470
  console.log(unmanagedRuntimeMessage(dest));
1419
1471
  } else {