@rtorcato/repo-tooling 3.10.0 → 3.10.1

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.
@@ -1,7 +1,9 @@
1
+ import { glob } from 'node:fs/promises';
1
2
  import path from 'node:path';
2
3
  import fs from 'fs-extra';
3
4
  import { copyPreset, getPackageRoot } from '../utils/copy-preset.js';
4
5
  import { LEGACY_TOOL_NAME } from '../utils/lockfile.js';
6
+ import { parseWorkspacePackages } from './misc.js';
5
7
  import { installSkillsInstallDocs } from './skills-install.js';
6
8
  /**
7
9
  * All agent rule files are generated from one source of truth — the shipped
@@ -41,6 +43,62 @@ function asObject(value) {
41
43
  ? value
42
44
  : null;
43
45
  }
46
+ /**
47
+ * True when `rel` resolves to something at or under `targetDir`. The entries
48
+ * this guards end up in `worktree.symlinkDirectories`, which Claude Code reads
49
+ * as symlink targets — an escaping path would point a worktree symlink outside
50
+ * the repo, so a workspace glob is never trusted to stay inside on its own.
51
+ */
52
+ function isInside(targetDir, rel) {
53
+ const outward = path.relative(path.resolve(targetDir), path.resolve(targetDir, rel));
54
+ return outward !== '' && !outward.startsWith('..') && !path.isAbsolute(outward);
55
+ }
56
+ /**
57
+ * The repo's own workspace globs, from `pnpm-workspace.yaml` and/or
58
+ * package.json `workspaces` (both array and `{ packages: [] }` forms). Both
59
+ * are read because a pnpm repo can keep its globs in either file.
60
+ *
61
+ * Negations (`!apps/x`) are dropped, and so is anything absolute or containing
62
+ * a `..` segment: a `packages: - '../shared-lib'` entry reads as plausible in a
63
+ * monorepo but would write an out-of-repo symlink target into settings.json.
64
+ */
65
+ async function workspaceGlobs(targetDir) {
66
+ const yamlFile = path.join(targetDir, 'pnpm-workspace.yaml');
67
+ const yaml = (await fs.pathExists(yamlFile)) ? await fs.readFile(yamlFile, 'utf-8') : '';
68
+ const pkg = asObject(await fs.readJson(path.join(targetDir, 'package.json')).catch(() => null));
69
+ const field = Array.isArray(pkg?.workspaces)
70
+ ? pkg.workspaces
71
+ : asObject(pkg?.workspaces)?.packages;
72
+ const declared = Array.isArray(field) ? field : [];
73
+ return [...parseWorkspacePackages(yaml), ...declared].filter((g) => typeof g === 'string' &&
74
+ !g.startsWith('!') &&
75
+ !path.isAbsolute(g) &&
76
+ !g.split(/[\\/]/).includes('..'));
77
+ }
78
+ /**
79
+ * The nested `<workspace>/node_modules` to symlink alongside the root one
80
+ * (#406). Without them a workspace-scoped command in a fresh worktree fails on
81
+ * a missing binary — `pnpm --filter <pkg> build` can't find its bin shims —
82
+ * which reads as a broken toolchain rather than a missing symlink.
83
+ *
84
+ * Derived from the repo's own workspace globs, never a hardcoded layout: this
85
+ * is a public CLI and every consumer nests its packages differently.
86
+ * Workspaces with no `node_modules` in the main checkout are skipped, because
87
+ * an entry pointing at nothing warns on every worktree creation.
88
+ */
89
+ export async function workspaceSymlinkDirs(targetDir) {
90
+ const globs = await workspaceGlobs(targetDir);
91
+ if (globs.length === 0)
92
+ return [];
93
+ const found = [];
94
+ for await (const match of glob(globs.map((g) => `${g}/node_modules`), { cwd: targetDir })) {
95
+ // Belt and braces: the glob result is what actually gets written, so it is
96
+ // re-checked for containment even though the input globs were filtered.
97
+ if (isInside(targetDir, match))
98
+ found.push(match.split(path.sep).join('/'));
99
+ }
100
+ return found.sort();
101
+ }
44
102
  /** Parsed `.claude/settings.json`, or null when absent, malformed, or not an object. */
45
103
  export async function readClaudeSettings(targetDir) {
46
104
  try {
@@ -74,10 +132,8 @@ export async function installClaudeSettings(targetDir) {
74
132
  if (!settings)
75
133
  return null;
76
134
  const existing = worktreeSymlinkDirs(settings);
77
- const symlinkDirectories = [
78
- ...existing,
79
- ...WORKTREE_SYMLINK_DIRS.filter((d) => !existing.includes(d)),
80
- ];
135
+ const wanted = [...WORKTREE_SYMLINK_DIRS, ...(await workspaceSymlinkDirs(targetDir))];
136
+ const symlinkDirectories = [...existing, ...wanted.filter((d) => !existing.includes(d))];
81
137
  const worktree = { ...asObject(settings.worktree), symlinkDirectories };
82
138
  await fs.outputJson(file, { ...settings, worktree }, { spaces: 2 });
83
139
  return CLAUDE_SETTINGS_FILE;
@@ -256,7 +256,7 @@ export async function ensurePackageManager(targetDir, version = detectPnpmVersio
256
256
  * YAML dependency. Matches `- 'apps/*'` / `- "apps/*"` / `- apps/*` entries
257
257
  * under the `packages:` key, stopping at the next top-level key.
258
258
  */
259
- function parseWorkspacePackages(yaml) {
259
+ export function parseWorkspacePackages(yaml) {
260
260
  const globs = [];
261
261
  let inPackages = false;
262
262
  for (const line of yaml.split('\n')) {
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
3
  import { hookHasUncommented } from '../../base/checks.js';
4
- import { CLAUDE_SETTINGS_FILE, readClaudeSettings, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
4
+ import { CLAUDE_SETTINGS_FILE, readClaudeSettings, workspaceSymlinkDirs, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
5
5
  import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
6
6
  const PACKAGE = '@rtorcato/repo-tooling';
7
7
  const NODE_MIN_MAJOR = 22;
@@ -980,13 +980,20 @@ export async function checkClaudeWorktreeSettings(dir) {
980
980
  hint: `Repair the JSON in ${CLAUDE_SETTINGS_FILE} by hand — \`fix ai\` refuses to overwrite it`,
981
981
  };
982
982
  }
983
- if (worktreeSymlinkDirs(settings).includes('node_modules')) {
984
- return { check, status: 'ok', detail: 'worktree.symlinkDirectories carries node_modules' };
983
+ const recorded = worktreeSymlinkDirs(settings);
984
+ const wanted = ['node_modules', ...(await workspaceSymlinkDirs(dir))];
985
+ const missing = wanted.filter((d) => !recorded.includes(d));
986
+ if (missing.length === 0) {
987
+ return {
988
+ check,
989
+ status: 'ok',
990
+ detail: `worktree.symlinkDirectories carries ${wanted.join(', ')}`,
991
+ };
985
992
  }
986
993
  return {
987
994
  check,
988
995
  status: 'optional-missing',
989
- detail: `${CLAUDE_SETTINGS_FILE} has no worktree.symlinkDirectories entry for node_modules`,
996
+ detail: `${CLAUDE_SETTINGS_FILE} has no worktree.symlinkDirectories entry for ${missing.join(', ')}`,
990
997
  hint,
991
998
  };
992
999
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "3.10.0",
3
+ "version": "3.10.1",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [