@rtorcato/repo-tooling 3.10.0 → 3.10.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/dist/base/fixers.js
CHANGED
|
@@ -27,6 +27,23 @@ import { detectLanguage } from '../cli/utils/detect-language.js';
|
|
|
27
27
|
import { resolveLanguageModule } from '../languages/registry.js';
|
|
28
28
|
import { applyGithubSettings } from './github-settings.js';
|
|
29
29
|
import { closeCompletedMilestones } from './milestones.js';
|
|
30
|
+
/**
|
|
31
|
+
* A fixer giving up on something the user has to resolve — a wrong flag, not a
|
|
32
|
+
* crash. Thrown rather than printed-and-exited because a fixer runs underneath
|
|
33
|
+
* `--json`: only the command layer knows whether the failure should surface as
|
|
34
|
+
* an error payload on stdout or a red line on stderr. `code` joins the same
|
|
35
|
+
* vocabulary as the command's own `no-lockfile` / `unknown-target` errors.
|
|
36
|
+
*/
|
|
37
|
+
export class FixerAbort extends Error {
|
|
38
|
+
code;
|
|
39
|
+
hint;
|
|
40
|
+
constructor(code, message, hint) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.hint = hint;
|
|
44
|
+
this.name = 'FixerAbort';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
30
47
|
/** The repo's language module, resolved from its marker files. */
|
|
31
48
|
async function moduleFor(targetDir) {
|
|
32
49
|
return resolveLanguageModule(await detectLanguage(targetDir));
|
|
@@ -34,16 +51,17 @@ async function moduleFor(targetDir) {
|
|
|
34
51
|
/**
|
|
35
52
|
* Where to install user-global skills, asking only when nothing resolves. Under
|
|
36
53
|
* `--yes` / `--json` a prompt is not available — `--json` would have its payload
|
|
37
|
-
* corrupted by one —
|
|
38
|
-
*
|
|
54
|
+
* corrupted by one — and guessing at a directory the user never mentioned is not
|
|
55
|
+
* an option either, so the run fails: `--skills-dir` is required in that case
|
|
56
|
+
* (#411). It used to warn and no-op, which exited 0 with an empty `filesWritten`
|
|
57
|
+
* that no `--json` consumer could tell apart from "already up to date".
|
|
39
58
|
*/
|
|
40
59
|
async function resolveInstallDir(explicit, assumeYes) {
|
|
41
60
|
const { dir } = await resolveSkillsDir(explicit);
|
|
42
61
|
if (dir)
|
|
43
62
|
return dir;
|
|
44
63
|
if (assumeYes) {
|
|
45
|
-
|
|
46
|
-
return null;
|
|
64
|
+
throw new FixerAbort('no-skills-dir', 'no ~/.claude/skills found, and --yes/--json cannot prompt for one', 'pass --skills-dir <path>');
|
|
47
65
|
}
|
|
48
66
|
const { answer } = await inquirer.prompt([
|
|
49
67
|
{
|
package/dist/cli/commands/fix.js
CHANGED
|
@@ -9,7 +9,7 @@ import { LOCKFILE_NAME, readLockfile, updateLockfileConfig, writeLockfile, } fro
|
|
|
9
9
|
import { runDoctor } from './doctor.js';
|
|
10
10
|
import { declinedInLock, lockfilePatchForTarget } from './fix-targets.js';
|
|
11
11
|
import { computeFileList } from './setup-presets.js';
|
|
12
|
-
import { BASE_FIXERS } from '../../base/fixers.js';
|
|
12
|
+
import { BASE_FIXERS, FixerAbort, } from '../../base/fixers.js';
|
|
13
13
|
import { FIXERS, readPackageJson } from '../../languages/js/fixers.js';
|
|
14
14
|
import { PERL_FIXERS } from '../../languages/perl/fixers.js';
|
|
15
15
|
import { PYTHON_FIXERS } from '../../languages/python/fixers.js';
|
|
@@ -398,9 +398,25 @@ export async function fixCommand(target, options = {}) {
|
|
|
398
398
|
console.log(chalk.gray(' skipped\n'));
|
|
399
399
|
return;
|
|
400
400
|
}
|
|
401
|
+
// ponytail: only the targeted path is guarded, because the only fixer that
|
|
402
|
+
// aborts is `claude-skills` and it is explicitOnly — the bulk loop below
|
|
403
|
+
// cannot reach it. A non-explicitOnly fixer that throws FixerAbort would
|
|
404
|
+
// surface as an unhandled rejection there; guard the loop when one exists.
|
|
401
405
|
const outcome = await applyFixer(fixer, effectiveResult, targetDir, pkg, lock, dryRun, silent, {
|
|
402
406
|
skillsDir: options.skillsDir,
|
|
403
407
|
assumeYes,
|
|
408
|
+
}).catch((err) => {
|
|
409
|
+
if (!(err instanceof FixerAbort))
|
|
410
|
+
throw err;
|
|
411
|
+
if (json) {
|
|
412
|
+
console.log(JSON.stringify({ directory: targetDir, target: fixer.target, error: err.code, hint: err.hint }, null, 2));
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
console.error(chalk.red(`\n❌ ${fixer.target}: ${err.message}`));
|
|
416
|
+
if (err.hint)
|
|
417
|
+
console.error(chalk.gray(` ${err.hint}\n`));
|
|
418
|
+
}
|
|
419
|
+
process.exit(1);
|
|
404
420
|
});
|
|
405
421
|
actions.push(recordFor(fixer.target, result.check, effectiveResult.status, outcome.dryRun ? 'dry-run' : 'applied', outcome.filesWritten, conflict));
|
|
406
422
|
if (json)
|
|
@@ -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
|
|
78
|
-
|
|
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
|
-
|
|
984
|
-
|
|
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
|
|
996
|
+
detail: `${CLAUDE_SETTINGS_FILE} has no worktree.symlinkDirectories entry for ${missing.join(', ')}`,
|
|
990
997
|
hint,
|
|
991
998
|
};
|
|
992
999
|
}
|
package/package.json
CHANGED
|
@@ -32,13 +32,14 @@ the protected branch — it would deadlock every PR.
|
|
|
32
32
|
|
|
33
33
|
**If you ever switch to real approvals** — a second GitHub account reviewing as
|
|
34
34
|
someone else, so `--approve` works and the `ai-ok-*` labels become unnecessary —
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
first check what else writes your branch protection. Any repo-settings tool that
|
|
36
|
+
treats `required_pull_request_reviews` as drift will PUT it back to `null` on its
|
|
37
|
+
next run, because required review deadlocks solo Dependabot auto-merge. Your
|
|
38
|
+
approval rule vanishes, merges hand themselves back to the labels, and nothing in
|
|
39
|
+
that tool's output ties the change to this pipeline. `@rtorcato/repo-tooling`,
|
|
40
|
+
which ships this skill, is one such tool — its repo-settings standard asserts
|
|
41
|
+
`required_pull_request_reviews: null`, so change that standard before you rely on
|
|
42
|
+
real approvals.
|
|
42
43
|
|
|
43
44
|
The same constraint makes everything an agent posts *look* hand-written by the
|
|
44
45
|
owner. So **every comment any agent leaves — review, blocked, gave-up, declined —
|