@phuc1403/musketeer 0.2.4 → 0.3.0
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/INSTALLATION.md +10 -0
- package/README.md +1 -1
- package/manifest.json +13 -2
- package/package.json +1 -1
- package/src/dotnet-build-props.js +163 -0
- package/src/dotnet-fs-scan.js +48 -0
- package/src/dotnet-scaffold-copier.js +72 -0
- package/src/dotnet-scaffold.js +175 -0
- package/src/reconcile.js +24 -0
- package/template/.claude/skills/adr-writer/SKILL.md +123 -78
- package/template/.claude/skills/adr-writer/references/adr-example.md +3 -1
- package/template/.claude/skills/adr-writer/references/adr-template.md +37 -46
- package/template/.claude/skills/tdd/dotnet-build-config.md +8 -0
- package/template/dotnet-scaffold/__SolutionName__.sln +77 -0
- package/template/dotnet-scaffold/global.json +6 -0
- package/template/dotnet-scaffold/src/__SolutionName__.Api/Program.cs +6 -0
- package/template/dotnet-scaffold/src/__SolutionName__.Api/Properties/launchSettings.json +38 -0
- package/template/dotnet-scaffold/src/__SolutionName__.Api/__SolutionName__.Api.csproj +15 -0
- package/template/dotnet-scaffold/src/__SolutionName__.Api/appsettings.Development.json +8 -0
- package/template/dotnet-scaffold/src/__SolutionName__.Api/appsettings.json +9 -0
- package/template/dotnet-scaffold/src/__SolutionName__.Application/__SolutionName__.Application.csproj +14 -0
- package/template/dotnet-scaffold/src/__SolutionName__.Domain/__SolutionName__.Domain.csproj +24 -0
- package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/__SolutionName__.Infrastructure.csproj +20 -0
- package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/__SolutionName__.Api.Tests.csproj +31 -0
- package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/__SolutionName__.Application.Tests.csproj +29 -0
- package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/__SolutionName__.Domain.Tests.csproj +29 -0
- package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/__SolutionName__.Infrastructure.Tests.csproj +29 -0
package/INSTALLATION.md
CHANGED
|
@@ -14,8 +14,18 @@ an elevation prompt is refused — it falls back to printing the exact manual co
|
|
|
14
14
|
| git | core, code-review | `winget install Git.Git` | `brew install git` | `apt/dnf/pacman install git` |
|
|
15
15
|
| Python 3.8+ | core (skill-creator) | `winget install Python.Python.3.12` | `brew install python@3.12` | `apt install python3 python3-pip` |
|
|
16
16
|
| Java 8+ | architecture (context-map) | `winget install Microsoft.OpenJDK` | `brew install --cask temurin` | `apt install openjdk-17-jdk` |
|
|
17
|
+
| adr-tools | architecture (adr-writer) | download the [release zip](https://github.com/npryce/adr-tools/releases), copy `src/*` into `C:\Program Files\Git\usr\bin` (Git Bash) | `brew install adr-tools` | `apt install adr-tools` · Arch: AUR `adr-tools` · Fedora/RHEL: clone + add `src/` to `PATH` |
|
|
17
18
|
| gh | code-review (PR mode) | `winget install GitHub.cli` | `brew install gh` | `apt install gh` |
|
|
18
19
|
|
|
20
|
+
> `adr-tools` is a set of bash scripts. On Windows it runs inside Git Bash (already required by
|
|
21
|
+
> core), so the skill works — but `musketeer muster` cannot detect it from `cmd.exe` and will print
|
|
22
|
+
> a manual-install line every run. Expected; ignore it once `adr config` works in Git Bash.
|
|
23
|
+
>
|
|
24
|
+
> Running `musketeer muster` non-interactively (CI, onboarding scripts)? The `apt`/`brew` install
|
|
25
|
+
> commands can prompt for `sudo` or hang waiting for input. Pre-install the prereqs yourself, or
|
|
26
|
+
> set `MUSKETEER_SKIP_INSTALL=1` to print the manual command instead of executing it (red team
|
|
27
|
+
> finding 8).
|
|
28
|
+
|
|
19
29
|
## Secrets (never auto-installed)
|
|
20
30
|
|
|
21
31
|
- `ANTHROPIC_API_KEY` — needed only by skill-creator's improve/loop scripts. You will be
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ musketeers are in this project; promote = upgrade the binary._
|
|
|
38
38
|
| **architecture** | off | adr-writer, architecture-characteristic-writer, context-map · CML validation hook |
|
|
39
39
|
| **hallmark** | off | hallmark, hallmark-explore, hallmark-loop · auditor/explorer agents |
|
|
40
40
|
| **code-review** | off | code-review skill + code-reviewer agent |
|
|
41
|
-
| **dotnet** | off | tdd, knowledge-crunching · EF migration guard hook |
|
|
41
|
+
| **dotnet** | off | tdd, knowledge-crunching · EF migration guard hook · always merges 4 quality-gate props into root `Directory.Build.props` (`MUSKETEER_SKIP_DOTNET_PROPS=1` to skip) · scaffolds a generic `src`/`tests` Clean Architecture skeleton when the project is blank |
|
|
42
42
|
| **design-docs** | off | inject-design-docs SessionStart hook |
|
|
43
43
|
|
|
44
44
|
The muster starts every musketeer **unselected**, except ones you already installed (pre-checked from
|
package/manifest.json
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"order": 1
|
|
67
67
|
}
|
|
68
68
|
],
|
|
69
|
-
"prereqs": ["java", "cm-cli"]
|
|
69
|
+
"prereqs": ["java", "cm-cli", "adr-tools"]
|
|
70
70
|
},
|
|
71
71
|
"hallmark": {
|
|
72
72
|
"label": "hallmark",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
},
|
|
98
98
|
"dotnet": {
|
|
99
99
|
"label": "dotnet",
|
|
100
|
-
"description": ".NET extras: tdd, knowledge-crunching + EF migration-guard & CONTEXT auto-load hooks.",
|
|
100
|
+
"description": ".NET extras: tdd, knowledge-crunching + EF migration-guard & CONTEXT auto-load hooks. Muster also always merges 4 quality-gate MSBuild properties into root Directory.Build.props (any dotnet-selected project, set MUSKETEER_SKIP_DOTNET_PROPS=1 to skip) and scaffolds a generic src/tests Clean Architecture skeleton when the project is genuinely blank.",
|
|
101
101
|
"locked": false,
|
|
102
102
|
"deps": [],
|
|
103
103
|
"files": [
|
|
@@ -217,6 +217,17 @@
|
|
|
217
217
|
"kind": "note",
|
|
218
218
|
"needs": ["java"]
|
|
219
219
|
},
|
|
220
|
+
"adr-tools": {
|
|
221
|
+
"detect": "adr config",
|
|
222
|
+
"kind": "package",
|
|
223
|
+
"needs": ["git"],
|
|
224
|
+
"install": {
|
|
225
|
+
"mac": "brew install adr-tools",
|
|
226
|
+
"linux": {
|
|
227
|
+
"apt": "sudo apt-get install -y adr-tools"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
220
231
|
"gh": {
|
|
221
232
|
"detect": "gh --version",
|
|
222
233
|
"kind": "package",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phuc1403/musketeer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Distributable custom Claude Code harness — one declarative command scaffolds a curated company of musketeers (skills/agents/hooks) into any project's .claude/.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { findDotnetProjectFiles } = require('./dotnet-fs-scan');
|
|
6
|
+
|
|
7
|
+
const QUALITY_GATE_PROPS = {
|
|
8
|
+
TreatWarningsAsErrors: 'true',
|
|
9
|
+
EnforceCodeStyleInBuild: 'true',
|
|
10
|
+
AnalysisLevel: 'latest-recommended',
|
|
11
|
+
AnalysisModeSecurity: 'All',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const PROPERTY_GROUP_OPEN_RE = /<PropertyGroup(\s[^>]*)?>/g;
|
|
15
|
+
|
|
16
|
+
function presenceRegex(tag) {
|
|
17
|
+
return new RegExp('<' + tag + '[\\s>]');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildMinimalFile(props) {
|
|
21
|
+
const lines = Object.entries(props)
|
|
22
|
+
.map(([k, v]) => ` <${k}>${v}</${k}>`)
|
|
23
|
+
.join('\n');
|
|
24
|
+
return `<Project>\n\n <PropertyGroup>\n${lines}\n </PropertyGroup>\n\n</Project>\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the first `<PropertyGroup>` opening tag with no `Condition=` attribute.
|
|
29
|
+
* Accepted limitation (same spirit as the `<Choose>/<When>` non-support): a
|
|
30
|
+
* self-closed `<PropertyGroup />` is matched as "unconditioned" and props get
|
|
31
|
+
* spliced in right after it as dangling siblings, not inside an element MSBuild
|
|
32
|
+
* would evaluate. Vanishingly rare in real Directory.Build.props files.
|
|
33
|
+
* @returns {{exists:boolean, index?:number}} index is the offset right after the opening tag
|
|
34
|
+
*/
|
|
35
|
+
function findUnconditionedInsertionPoint(content) {
|
|
36
|
+
PROPERTY_GROUP_OPEN_RE.lastIndex = 0;
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = PROPERTY_GROUP_OPEN_RE.exec(content)) !== null) {
|
|
39
|
+
const attrs = match[1] || '';
|
|
40
|
+
if (!/Condition\s*=/.test(attrs)) {
|
|
41
|
+
return { exists: true, index: match.index + match[0].length };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { exists: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function atomicWrite(filePath, content) {
|
|
48
|
+
const tmp = filePath + '.tmp';
|
|
49
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
50
|
+
fs.renameSync(tmp, filePath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Additive-only merge of MSBuild properties into a Directory.Build.props-shaped
|
|
55
|
+
* file. Never removes or alters an existing tag (any value, any attributes) —
|
|
56
|
+
* only adds tags that are entirely absent. Writes via temp-file + rename so an
|
|
57
|
+
* interrupted write never leaves a truncated/corrupted file, and skips writing
|
|
58
|
+
* entirely when there is nothing to add.
|
|
59
|
+
* @param {string} filePath
|
|
60
|
+
* @param {Record<string,string>} props ordered {tagName: value} map
|
|
61
|
+
* @param {string} [projectDir] containment root; `filePath` must resolve inside it or
|
|
62
|
+
* mergeProps throws instead of writing. Defaults to `path.dirname(filePath)` when
|
|
63
|
+
* omitted so containment is always enforced against SOME root — never silently skipped.
|
|
64
|
+
* @returns {{created:boolean, added:string[]}}
|
|
65
|
+
*/
|
|
66
|
+
function mergeProps(filePath, props, projectDir) {
|
|
67
|
+
const resolved = path.resolve(filePath);
|
|
68
|
+
const root = path.resolve(projectDir || path.dirname(filePath));
|
|
69
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
70
|
+
throw new Error(`mergeProps: "${filePath}" resolves outside project root "${projectDir || path.dirname(filePath)}"`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let existing;
|
|
74
|
+
try {
|
|
75
|
+
existing = fs.readFileSync(resolved, 'utf8');
|
|
76
|
+
} catch {
|
|
77
|
+
existing = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (existing === null) {
|
|
81
|
+
atomicWrite(resolved, buildMinimalFile(props));
|
|
82
|
+
return { created: true, added: Object.keys(props) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const added = Object.keys(props).filter((tag) => !presenceRegex(tag).test(existing));
|
|
86
|
+
if (added.length === 0) return { created: false, added: [] };
|
|
87
|
+
|
|
88
|
+
const missingLines = added.map((tag) => ` <${tag}>${props[tag]}</${tag}>`).join('\n');
|
|
89
|
+
const insertion = findUnconditionedInsertionPoint(existing);
|
|
90
|
+
|
|
91
|
+
let updated;
|
|
92
|
+
if (insertion.exists) {
|
|
93
|
+
updated = existing.slice(0, insertion.index) + '\n' + missingLines + existing.slice(insertion.index);
|
|
94
|
+
} else {
|
|
95
|
+
const insertBefore = existing.lastIndexOf('</Project>');
|
|
96
|
+
if (insertBefore === -1) {
|
|
97
|
+
throw new Error(`mergeProps: "${resolved}" has no </Project> closing tag`);
|
|
98
|
+
}
|
|
99
|
+
const block = ` <PropertyGroup>\n${missingLines}\n </PropertyGroup>\n`;
|
|
100
|
+
updated = existing.slice(0, insertBefore) + block + existing.slice(insertBefore);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
atomicWrite(resolved, updated);
|
|
104
|
+
return { created: false, added };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ancestor directories of `fileDir`, starting at `fileDir` itself, up to (but
|
|
109
|
+
* excluding) `root`.
|
|
110
|
+
*/
|
|
111
|
+
function ancestorsUpTo(fileDir, root) {
|
|
112
|
+
const dirs = [];
|
|
113
|
+
let cur = path.resolve(fileDir);
|
|
114
|
+
while (cur !== root && cur.startsWith(root + path.sep)) {
|
|
115
|
+
dirs.push(cur);
|
|
116
|
+
cur = path.dirname(cur);
|
|
117
|
+
}
|
|
118
|
+
return dirs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Merge the 4 quality-gate properties into `projectDir`'s root
|
|
123
|
+
* Directory.Build.props, always (for any dotnet-selected project — confirmed
|
|
124
|
+
* scope, see plan.md §Red Team Review). Detects and logs (but does not block
|
|
125
|
+
* on) nested projects that already have a closer Directory.Build.props between
|
|
126
|
+
* them and root — MSBuild's "nearest file wins" means the root write would be
|
|
127
|
+
* silently invisible to those projects otherwise.
|
|
128
|
+
* @param {string} projectDir
|
|
129
|
+
* @param {(msg:string)=>void} log
|
|
130
|
+
* @returns {{created:boolean, added:string[], shadowed:string[]}}
|
|
131
|
+
*/
|
|
132
|
+
function ensureDirectoryBuildProps(projectDir, log) {
|
|
133
|
+
const root = path.resolve(projectDir);
|
|
134
|
+
const filePath = path.join(root, 'Directory.Build.props');
|
|
135
|
+
|
|
136
|
+
const shadowed = [];
|
|
137
|
+
for (const file of findDotnetProjectFiles(root)) {
|
|
138
|
+
const shadow = ancestorsUpTo(path.dirname(file), root).some((dir) =>
|
|
139
|
+
fs.existsSync(path.join(dir, 'Directory.Build.props'))
|
|
140
|
+
);
|
|
141
|
+
if (shadow) shadowed.push(file);
|
|
142
|
+
}
|
|
143
|
+
if (shadowed.length > 0) {
|
|
144
|
+
log(
|
|
145
|
+
`dotnet: warning — ${shadowed.length} project(s) shadowed by a nearer Directory.Build.props, ` +
|
|
146
|
+
`won't see root quality gates: ${shadowed.join(', ')}`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = mergeProps(filePath, QUALITY_GATE_PROPS, root);
|
|
151
|
+
|
|
152
|
+
if (result.created) {
|
|
153
|
+
log(`dotnet: created Directory.Build.props with ${result.added.length} quality-gate props`);
|
|
154
|
+
} else if (result.added.length > 0) {
|
|
155
|
+
log(`dotnet: added ${result.added.length} quality-gate prop(s) to Directory.Build.props (${result.added.join(', ')})`);
|
|
156
|
+
} else {
|
|
157
|
+
log('dotnet: Directory.Build.props already up to date (additive-once, not force-updated)');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { ...result, shadowed };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { mergeProps, ensureDirectoryBuildProps, QUALITY_GATE_PROPS };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const EXCLUDED_DIR_NAMES = new Set(['.git', 'node_modules', 'bin', 'obj', '.claude']);
|
|
7
|
+
const DOTNET_PROJECT_EXTENSIONS = new Set(['.sln', '.csproj', '.fsproj', '.vbproj']);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Recursively find every .NET project/solution file under rootDir.
|
|
11
|
+
* Symlink-safe (never follows a symlinked entry, so no cycle risk) and tolerant
|
|
12
|
+
* of unreadable directories — this is a best-effort scan, not a security
|
|
13
|
+
* boundary, so a permission error on one subtree just skips it silently rather
|
|
14
|
+
* than aborting the whole scan.
|
|
15
|
+
* @param {string} rootDir
|
|
16
|
+
* @returns {string[]} absolute paths, one per matched file
|
|
17
|
+
*/
|
|
18
|
+
function findDotnetProjectFiles(rootDir) {
|
|
19
|
+
const results = [];
|
|
20
|
+
walk(rootDir, results);
|
|
21
|
+
return results;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function walk(dir, results) {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
try {
|
|
33
|
+
if (entry.isSymbolicLink()) continue;
|
|
34
|
+
const full = path.join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (EXCLUDED_DIR_NAMES.has(entry.name)) continue;
|
|
37
|
+
walk(full, results);
|
|
38
|
+
} else if (entry.isFile()) {
|
|
39
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
40
|
+
if (DOTNET_PROJECT_EXTENSIONS.has(ext)) results.push(path.resolve(full));
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Unreadable entry (permission error, race with external deletion, etc.) — skip it.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { findDotnetProjectFiles, DOTNET_PROJECT_EXTENSIONS, EXCLUDED_DIR_NAMES };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function containedPath(root, candidate) {
|
|
7
|
+
const resolvedRoot = path.resolve(root);
|
|
8
|
+
const resolved = path.resolve(candidate);
|
|
9
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) return null;
|
|
10
|
+
return resolved;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function substitute(text, token, replacement) {
|
|
14
|
+
return text.split(token).join(replacement);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Recursively copy every file under srcDir into stagingDir, substituting
|
|
19
|
+
* `token` with `replacement` in both path segments and file contents. Every
|
|
20
|
+
* computed destination path is validated to stay under `stagingDir` — rejects
|
|
21
|
+
* (throws) rather than writing outside it.
|
|
22
|
+
* @param {string} srcDir
|
|
23
|
+
* @param {string} stagingDir
|
|
24
|
+
* @param {string} token
|
|
25
|
+
* @param {string} replacement
|
|
26
|
+
*/
|
|
27
|
+
function copyTemplateWithTokenSubstitution(srcDir, stagingDir, token, replacement) {
|
|
28
|
+
walk(srcDir, stagingDir, stagingDir, token, replacement);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walk(currentSrc, currentDest, stagingRoot, token, replacement) {
|
|
32
|
+
const entries = fs.readdirSync(currentSrc, { withFileTypes: true });
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const substitutedName = substitute(entry.name, token, replacement);
|
|
35
|
+
const srcPath = path.join(currentSrc, entry.name);
|
|
36
|
+
const destPath = containedPath(stagingRoot, path.join(currentDest, substitutedName));
|
|
37
|
+
if (!destPath) {
|
|
38
|
+
throw new Error(`copyTemplateWithTokenSubstitution: computed path escapes staging dir: "${substitutedName}"`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (entry.isDirectory()) {
|
|
42
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
43
|
+
walk(srcPath, destPath, stagingRoot, token, replacement);
|
|
44
|
+
} else if (entry.isFile()) {
|
|
45
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
46
|
+
const content = fs.readFileSync(srcPath, 'utf8');
|
|
47
|
+
fs.writeFileSync(destPath, substitute(content, token, replacement), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Move every top-level entry from stagingDir into projectDir. Pre-checks all
|
|
54
|
+
* names for collisions before moving any of them, then moves each via
|
|
55
|
+
* fs.renameSync (atomic per entry on the same filesystem). Throws instead of
|
|
56
|
+
* silently overwriting if a destination name already exists.
|
|
57
|
+
* @param {string} stagingDir
|
|
58
|
+
* @param {string} projectDir
|
|
59
|
+
*/
|
|
60
|
+
function promoteStagedScaffold(stagingDir, projectDir) {
|
|
61
|
+
const entries = fs.readdirSync(stagingDir, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (fs.existsSync(path.join(projectDir, entry.name))) {
|
|
64
|
+
throw new Error(`promoteStagedScaffold: "${entry.name}" already exists at destination`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
fs.renameSync(path.join(stagingDir, entry.name), path.join(projectDir, entry.name));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { copyTemplateWithTokenSubstitution, promoteStagedScaffold };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { findDotnetProjectFiles } = require('./dotnet-fs-scan');
|
|
7
|
+
const { copyTemplateWithTokenSubstitution, promoteStagedScaffold } = require('./dotnet-scaffold-copier');
|
|
8
|
+
|
|
9
|
+
const OTHER_STACK_MANIFESTS = ['package.json', 'go.mod', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'Cargo.toml'];
|
|
10
|
+
|
|
11
|
+
// Day-1 bootstrap artifacts that don't count as "this repo is occupied" —
|
|
12
|
+
// broadened post-validation-interview so a repo with only initial tooling/IDE
|
|
13
|
+
// config isn't wrongly treated as occupied. Directory.Build.props is here for
|
|
14
|
+
// the same reason `.claude` is: reconcile.js's wiring always runs
|
|
15
|
+
// ensureDirectoryBuildProps() before scaffoldDotnetProject(), so this file
|
|
16
|
+
// already exists by the time this guard runs on every single dotnet muster —
|
|
17
|
+
// including the very first one on a genuinely blank repo.
|
|
18
|
+
const OCCUPANCY_ALLOWLIST = new Set([
|
|
19
|
+
'.git', '.claude', '.gitignore', '.gitattributes', '.editorconfig', '.musketeer.json',
|
|
20
|
+
'license', 'changelog.md', '.vscode', '.idea', '.github', 'directory.build.props',
|
|
21
|
+
'.ds_store', 'thumbs.db', 'desktop.ini',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const STAGING_DIR_PREFIX = '.musketeer-dotnet-staging-';
|
|
25
|
+
|
|
26
|
+
const SOLUTION_NAME_TOKEN = '__SolutionName__';
|
|
27
|
+
const GUID_TOKEN_NAMES = [
|
|
28
|
+
'__SlnFolderGuid_Src__',
|
|
29
|
+
'__SlnFolderGuid_Tests__',
|
|
30
|
+
'__ProjectGuid_Domain__',
|
|
31
|
+
'__ProjectGuid_Application__',
|
|
32
|
+
'__ProjectGuid_Infrastructure__',
|
|
33
|
+
'__ProjectGuid_Api__',
|
|
34
|
+
'__ProjectGuid_DomainTests__',
|
|
35
|
+
'__ProjectGuid_ApplicationTests__',
|
|
36
|
+
'__ProjectGuid_InfrastructureTests__',
|
|
37
|
+
'__ProjectGuid_ApiTests__',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function isAllowlistedRootEntry(name) {
|
|
41
|
+
if (OCCUPANCY_ALLOWLIST.has(name.toLowerCase())) return true;
|
|
42
|
+
return /^readme(\..+)?$/i.test(name) || /^license(\..+)?$/i.test(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A genuinely empty directory holds no stack signal — it can't itself mean
|
|
47
|
+
* "this repo is occupied", so it doesn't count against the root listing
|
|
48
|
+
* (e.g. a leftover bare `src`/`tests` pair from an unrelated convention).
|
|
49
|
+
*/
|
|
50
|
+
function isEmptyDir(fullPath) {
|
|
51
|
+
try {
|
|
52
|
+
const st = fs.statSync(fullPath);
|
|
53
|
+
return st.isDirectory() && fs.readdirSync(fullPath).length === 0;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether `projectDir` is genuinely blank enough to scaffold into.
|
|
61
|
+
* Bias toward skipping: a false-skip is safe (user scaffolds manually later),
|
|
62
|
+
* a false-scaffold corrupts a real project.
|
|
63
|
+
* @param {string} projectDir
|
|
64
|
+
* @returns {{scaffold:boolean, reason?:string}}
|
|
65
|
+
*/
|
|
66
|
+
function shouldScaffold(projectDir) {
|
|
67
|
+
const dotnetFiles = findDotnetProjectFiles(projectDir);
|
|
68
|
+
if (dotnetFiles.length > 0) {
|
|
69
|
+
return { scaffold: false, reason: `existing .NET project file(s) found (e.g. ${dotnetFiles[0]})` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let rootEntries;
|
|
73
|
+
try {
|
|
74
|
+
rootEntries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { scaffold: false, reason: `cannot read project root: ${err.message}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const names = rootEntries.map((e) => e.name);
|
|
80
|
+
const lowerNames = new Set(names.map((n) => n.toLowerCase()));
|
|
81
|
+
const otherStackManifest = OTHER_STACK_MANIFESTS.find((m) => lowerNames.has(m.toLowerCase()));
|
|
82
|
+
if (otherStackManifest) {
|
|
83
|
+
return { scaffold: false, reason: `other-stack manifest found: ${otherStackManifest}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const unexpected = names.filter((n) => {
|
|
87
|
+
if (isAllowlistedRootEntry(n)) return false;
|
|
88
|
+
if (n.startsWith(STAGING_DIR_PREFIX)) return false; // leftover from a crashed prior scaffold attempt
|
|
89
|
+
return !isEmptyDir(path.join(projectDir, n));
|
|
90
|
+
});
|
|
91
|
+
if (unexpected.length > 0) {
|
|
92
|
+
return { scaffold: false, reason: `project root already occupied by: ${unexpected.join(', ')}` };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { scaffold: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Derive a PascalCase solution name from a project directory's basename.
|
|
100
|
+
* Unicode-aware (splits on `\p{L}\p{N}` boundaries, not ASCII `\w`) so
|
|
101
|
+
* non-Latin-script folder names aren't collapsed to the generic fallback.
|
|
102
|
+
* @param {string} projectDir
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
function deriveSolutionName(projectDir) {
|
|
106
|
+
const base = path.basename(path.resolve(projectDir));
|
|
107
|
+
const segments = base.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
108
|
+
const pascal = segments
|
|
109
|
+
.map((s) => {
|
|
110
|
+
const chars = Array.from(s);
|
|
111
|
+
return chars[0].toUpperCase() + chars.slice(1).join('');
|
|
112
|
+
})
|
|
113
|
+
.join('');
|
|
114
|
+
|
|
115
|
+
if (!pascal) return 'App';
|
|
116
|
+
if (/^\p{N}/u.test(pascal)) return `App${pascal}`;
|
|
117
|
+
return pascal;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function substituteFreshGuids(stagingDir, solutionName) {
|
|
121
|
+
const slnPath = path.join(stagingDir, `${solutionName}.sln`);
|
|
122
|
+
let content = fs.readFileSync(slnPath, 'utf8');
|
|
123
|
+
for (const tokenName of GUID_TOKEN_NAMES) {
|
|
124
|
+
content = content.split(tokenName).join(`{${crypto.randomUUID().toUpperCase()}}`);
|
|
125
|
+
}
|
|
126
|
+
fs.writeFileSync(slnPath, content, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Scaffold a generic src/tests Clean Architecture skeleton into `projectDir`,
|
|
131
|
+
* only when `shouldScaffold` confirms it's genuinely blank. No-op (not an
|
|
132
|
+
* error) when the guard trips. Stages the whole copy in a temp directory and
|
|
133
|
+
* only promotes it into `projectDir` on full success, so a mid-copy failure
|
|
134
|
+
* leaves the real project untouched.
|
|
135
|
+
* @param {string} projectDir
|
|
136
|
+
* @param {string} templateScaffoldDir
|
|
137
|
+
* @param {(msg:string)=>void} log
|
|
138
|
+
* @returns {{scaffolded:boolean, solutionName?:string, reason?:string}}
|
|
139
|
+
*/
|
|
140
|
+
function scaffoldDotnetProject(projectDir, templateScaffoldDir, log) {
|
|
141
|
+
const guard = shouldScaffold(projectDir);
|
|
142
|
+
if (!guard.scaffold) {
|
|
143
|
+
log(`dotnet: scaffold skipped (${guard.reason})`);
|
|
144
|
+
return { scaffolded: false, reason: guard.reason };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const solutionName = deriveSolutionName(projectDir);
|
|
148
|
+
// Stage under projectDir itself (not the OS temp dir) so promoteStagedScaffold's
|
|
149
|
+
// fs.renameSync is guaranteed same-filesystem — renaming across devices/volumes
|
|
150
|
+
// (common for OS temp dirs vs. a project on another drive, or in containers where
|
|
151
|
+
// /tmp is a separate overlay from the bind-mounted workspace) throws EXDEV and would
|
|
152
|
+
// silently defeat scaffolding forever on those topologies.
|
|
153
|
+
const stagingDir = fs.mkdtempSync(path.join(projectDir, STAGING_DIR_PREFIX));
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
copyTemplateWithTokenSubstitution(templateScaffoldDir, stagingDir, SOLUTION_NAME_TOKEN, solutionName);
|
|
157
|
+
substituteFreshGuids(stagingDir, solutionName);
|
|
158
|
+
promoteStagedScaffold(stagingDir, projectDir);
|
|
159
|
+
} finally {
|
|
160
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
log(`dotnet: scaffolded src/tests skeleton as "${solutionName}"`);
|
|
164
|
+
return { scaffolded: true, solutionName };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Absolute path to the packaged template/dotnet-scaffold directory.
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
function defaultTemplateScaffoldDir() {
|
|
172
|
+
return path.join(__dirname, '..', 'template', 'dotnet-scaffold');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { shouldScaffold, deriveSolutionName, scaffoldDotnetProject, defaultTemplateScaffoldDir };
|
package/src/reconcile.js
CHANGED
|
@@ -12,12 +12,16 @@ const provisioner = require('./provisioner');
|
|
|
12
12
|
* templateDir, muster, provisioner, log, now) to stay hermetic.
|
|
13
13
|
*/
|
|
14
14
|
function defaultDeps() {
|
|
15
|
+
const dotnetScaffold = require('./dotnet-scaffold');
|
|
15
16
|
return {
|
|
16
17
|
manifest: loadManifest(),
|
|
17
18
|
templateDir: templateClaudeDir(),
|
|
18
19
|
settingsMerger,
|
|
19
20
|
provisioner,
|
|
20
21
|
muster: require('./muster'),
|
|
22
|
+
dotnetBuildProps: require('./dotnet-build-props'),
|
|
23
|
+
dotnetScaffold,
|
|
24
|
+
dotnetScaffoldTemplateDir: dotnetScaffold.defaultTemplateScaffoldDir(),
|
|
21
25
|
now: () => new Date().toISOString(),
|
|
22
26
|
log: (m) => process.stdout.write(m + '\n'),
|
|
23
27
|
};
|
|
@@ -55,6 +59,26 @@ async function run(opts, overrides = {}) {
|
|
|
55
59
|
copier.apply(plan, d.templateDir, projectDir);
|
|
56
60
|
d.settingsMerger.generate(desired, projectDir);
|
|
57
61
|
|
|
62
|
+
// Deliberate exception to the .claude/-only invariant (see copier.js's containedPath
|
|
63
|
+
// comment) — the dotnet company deterministically touches the project's real root,
|
|
64
|
+
// not just .claude/.
|
|
65
|
+
if (desired.ids.includes('dotnet')) {
|
|
66
|
+
if (process.env.MUSKETEER_SKIP_DOTNET_PROPS) {
|
|
67
|
+
d.log('dotnet: Directory.Build.props merge skipped (MUSKETEER_SKIP_DOTNET_PROPS set)');
|
|
68
|
+
} else {
|
|
69
|
+
try {
|
|
70
|
+
d.dotnetBuildProps.ensureDirectoryBuildProps(projectDir, d.log);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
d.log(`dotnet: Directory.Build.props merge failed (${err.message}) — continuing`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
d.dotnetScaffold.scaffoldDotnetProject(projectDir, d.dotnetScaffoldTemplateDir, d.log);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
d.log(`dotnet: src/tests scaffold failed (${err.message}) — continuing`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
await d.provisioner.ensure(desired.prereqs, manifest, {
|
|
59
83
|
projectDir,
|
|
60
84
|
templateDir: d.templateDir,
|