azcodr 2.0.0 → 2.1.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/.agents/hooks.json +3 -3
- package/.github/workflows/publish.yml +0 -4
- package/bin/azcodr.js +9 -4
- package/lib/cli-parse.d.ts +32 -0
- package/lib/cli-parse.d.ts.map +1 -0
- package/lib/cli-parse.js +45 -41
- package/lib/cli-parse.js.map +1 -0
- package/lib/cli-target.d.ts +66 -0
- package/lib/cli-target.d.ts.map +1 -0
- package/lib/cli-target.js +89 -96
- package/lib/cli-target.js.map +1 -0
- package/lib/cli.d.ts +40 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +122 -136
- package/lib/cli.js.map +1 -0
- package/lib/errors.d.ts +39 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +16 -18
- package/lib/errors.js.map +1 -0
- package/lib/git.d.ts +15 -0
- package/lib/git.d.ts.map +1 -0
- package/lib/git.js +25 -22
- package/lib/git.js.map +1 -0
- package/lib/guards.d.ts +35 -0
- package/lib/guards.d.ts.map +1 -0
- package/lib/guards.js +62 -63
- package/lib/guards.js.map +1 -0
- package/lib/index.d.ts +6 -199
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +4 -5
- package/lib/index.js.map +1 -0
- package/lib/links.d.ts +28 -0
- package/lib/links.d.ts.map +1 -0
- package/lib/links.js +105 -99
- package/lib/links.js.map +1 -0
- package/lib/permissions.d.ts +9 -0
- package/lib/permissions.d.ts.map +1 -0
- package/lib/permissions.js +36 -36
- package/lib/permissions.js.map +1 -0
- package/lib/repo.d.ts +20 -0
- package/lib/repo.d.ts.map +1 -0
- package/lib/repo.js +73 -71
- package/lib/repo.js.map +1 -0
- package/lib/scaffold.d.ts +80 -0
- package/lib/scaffold.d.ts.map +1 -0
- package/lib/scaffold.js +159 -196
- package/lib/scaffold.js.map +1 -0
- package/memory.md +16 -0
- package/package.json +13 -3
- package/scripts/test_coverage.js +2 -2
- package/scripts/validate/adr.js +11 -7
- package/scripts/validate/io.js +7 -9
- package/scripts/validate/links.js +6 -7
- package/scripts/validate/parity.js +5 -7
- package/scripts/validate/root.js +6 -7
- package/scripts/validate/rules.js +7 -9
- package/scripts/validate/skills.js +8 -10
- package/scripts/validate/text.js +3 -5
- package/scripts/validate-cli.js +1 -2
- package/scripts/validate.js +37 -19
- package/src/cli-parse.ts +77 -0
- package/src/cli-target.ts +167 -0
- package/src/cli.ts +240 -0
- package/src/errors.ts +35 -0
- package/src/git.ts +34 -0
- package/src/guards.ts +101 -0
- package/src/index.ts +39 -0
- package/src/links.ts +139 -0
- package/src/permissions.ts +42 -0
- package/src/repo.ts +94 -0
- package/src/scaffold.ts +273 -0
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const fs = require('node:fs');
|
|
1
|
+
import fs from 'node:fs';
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
4
|
* Harness-parity verdicts, kept free of filesystem I/O.
|
|
@@ -15,7 +13,7 @@ const fs = require('node:fs');
|
|
|
15
13
|
* The three target spellings a harness-parity file may legitimately use.
|
|
16
14
|
* Pure so it can be unit-tested without touching the filesystem.
|
|
17
15
|
*/
|
|
18
|
-
function isValidAgentsTarget(target, agentsFile) {
|
|
16
|
+
export function isValidAgentsTarget(target, agentsFile) {
|
|
19
17
|
const normalized = target.replace(/\\/g, '/');
|
|
20
18
|
const rootNormalized = agentsFile.replace(/\\/g, '/');
|
|
21
19
|
return normalized === 'AGENTS.md' || normalized === './AGENTS.md' || normalized === rootNormalized;
|
|
@@ -82,7 +80,7 @@ function fileVerdict(ctx) {
|
|
|
82
80
|
/**
|
|
83
81
|
* Decides whether a harness-parity entry is healthy, drifted, or outright wrong.
|
|
84
82
|
*/
|
|
85
|
-
function evaluateParityTarget(ctx) {
|
|
83
|
+
export function evaluateParityTarget(ctx) {
|
|
86
84
|
if (ctx.isSymlink) return symlinkVerdict(ctx);
|
|
87
85
|
if (ctx.isFile) return fileVerdict(ctx);
|
|
88
86
|
// Anything lstat saw that is neither a symlink nor a regular file (a
|
|
@@ -97,7 +95,7 @@ function evaluateParityTarget(ctx) {
|
|
|
97
95
|
*
|
|
98
96
|
* @returns {{created: boolean, strategy: 'symlink'|'pointer'|null, reason: string|null}}
|
|
99
97
|
*/
|
|
100
|
-
function createLowercaseParityLink(lowerPath, fsImpl = fs) {
|
|
98
|
+
export function createLowercaseParityLink(lowerPath, fsImpl = fs) {
|
|
101
99
|
try {
|
|
102
100
|
fsImpl.symlinkSync('AGENTS.md', lowerPath);
|
|
103
101
|
return { created: true, strategy: 'symlink', reason: null };
|
|
@@ -117,7 +115,7 @@ function createLowercaseParityLink(lowerPath, fsImpl = fs) {
|
|
|
117
115
|
}
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
|
|
118
|
+
export default {
|
|
121
119
|
isValidAgentsTarget,
|
|
122
120
|
evaluateParityTarget,
|
|
123
121
|
createLowercaseParityLink
|
package/scripts/validate/root.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const { readTextOrFail } = require('./io.js');
|
|
6
|
-
const { evaluateParityTarget, createLowercaseParityLink } = require('./parity.js');
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readTextOrFail } from './io.js';
|
|
4
|
+
import { evaluateParityTarget, createLowercaseParityLink } from './parity.js';
|
|
7
5
|
|
|
8
6
|
function reportAgentsLineBudget(agentsText, ctx) {
|
|
9
7
|
const lines = agentsText.split('\n').length;
|
|
@@ -181,4 +179,5 @@ function phaseRootConfig(ctx) {
|
|
|
181
179
|
checkGitignore(ctx);
|
|
182
180
|
}
|
|
183
181
|
|
|
184
|
-
|
|
182
|
+
export { phaseRootConfig, checkParity, checkAgentsFile };
|
|
183
|
+
export default { phaseRootConfig, checkParity, checkAgentsFile };
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readTextOrFail, walkMarkdown } from './io.js';
|
|
4
|
+
import { stripFencedCode } from './text.js';
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const { readTextOrFail, walkMarkdown } = require('./io.js');
|
|
6
|
-
const { stripFencedCode } = require('./text.js');
|
|
7
|
-
|
|
8
|
-
function checkOneRule(fp, name, ctx) {
|
|
6
|
+
export function checkOneRule(fp, name, ctx) {
|
|
9
7
|
const content = readTextOrFail(fp, `Rule ${name}`, ctx.fail);
|
|
10
8
|
if (content === null) return;
|
|
11
9
|
const stat = fs.statSync(fp);
|
|
@@ -17,7 +15,7 @@ function checkOneRule(fp, name, ctx) {
|
|
|
17
15
|
if (stat.size > 24000) ctx.warn(`Rule ${name} exceeds 24KB token-economy cap (${stat.size} bytes)`);
|
|
18
16
|
}
|
|
19
17
|
|
|
20
|
-
function phaseRules(ctx) {
|
|
18
|
+
export function phaseRules(ctx) {
|
|
21
19
|
ctx.log('');
|
|
22
20
|
ctx.heading('2. Checking Progressive Disclosure Rules...');
|
|
23
21
|
const rulesDir = path.join(ctx.workspaceRoot, 'docs', 'rules');
|
|
@@ -41,4 +39,4 @@ function phaseRules(ctx) {
|
|
|
41
39
|
return count;
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
export default { phaseRules, checkOneRule };
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readTextOrFail, readSkillFolders } from './io.js';
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const { readTextOrFail, readSkillFolders } = require('./io.js');
|
|
6
|
-
|
|
7
|
-
function frontMatterBlock(content, skill, fail) {
|
|
5
|
+
export function frontMatterBlock(content, skill, fail) {
|
|
8
6
|
const lines = content.split('\n');
|
|
9
7
|
if (lines[0].trim() !== '---') {
|
|
10
8
|
fail(`Skill '${skill}' missing opening front matter delimiter (---)`);
|
|
@@ -26,7 +24,7 @@ function frontMatterBlock(content, skill, fail) {
|
|
|
26
24
|
return frontMatter;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
function extractSkillDescription(frontMatter) {
|
|
27
|
+
export function extractSkillDescription(frontMatter) {
|
|
30
28
|
const descMatch = frontMatter.match(/^description:\s*(.+)$/m);
|
|
31
29
|
const desc = descMatch ? descMatch[1].trim() : '';
|
|
32
30
|
if (!/^[>|][-+]?$/.test(desc)) return desc;
|
|
@@ -60,7 +58,7 @@ function checkSkillBody(checked, ctx) {
|
|
|
60
58
|
}
|
|
61
59
|
}
|
|
62
60
|
|
|
63
|
-
function checkOneSkill(skill, skillsDir, ctx) {
|
|
61
|
+
export function checkOneSkill(skill, skillsDir, ctx) {
|
|
64
62
|
const skillFile = path.join(skillsDir, skill, 'SKILL.md');
|
|
65
63
|
if (!fs.existsSync(skillFile)) {
|
|
66
64
|
ctx.fail(`Skill '${skill}' missing SKILL.md`);
|
|
@@ -75,7 +73,7 @@ function checkOneSkill(skill, skillsDir, ctx) {
|
|
|
75
73
|
checkSkillBody({ content, desc, skill }, ctx);
|
|
76
74
|
}
|
|
77
75
|
|
|
78
|
-
function phaseSkills(ctx) {
|
|
76
|
+
export function phaseSkills(ctx) {
|
|
79
77
|
ctx.log('');
|
|
80
78
|
ctx.heading('3. Checking Specialized Skills (.agents/skills)...');
|
|
81
79
|
const skillsDir = path.join(ctx.workspaceRoot, '.agents', 'skills');
|
|
@@ -93,4 +91,4 @@ function phaseSkills(ctx) {
|
|
|
93
91
|
return count;
|
|
94
92
|
}
|
|
95
93
|
|
|
96
|
-
|
|
94
|
+
export default { phaseSkills, checkOneSkill, extractSkillDescription, frontMatterBlock };
|
package/scripts/validate/text.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Text helpers shared by the validator phases.
|
|
5
3
|
*
|
|
@@ -8,7 +6,7 @@
|
|
|
8
6
|
* would mistake documentation ABOUT the contract for the contract itself.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
|
-
function stripHtmlComments(text) {
|
|
9
|
+
export function stripHtmlComments(text) {
|
|
12
10
|
// Remove HTML comments, tolerating unclosed ones. A stray `<!--` from an
|
|
13
11
|
// editor's "comment selection" must not silently hide every ADR after it.
|
|
14
12
|
return text
|
|
@@ -22,8 +20,8 @@ function stripHtmlComments(text) {
|
|
|
22
20
|
* "Core Mandate" lines sit inside a ```md fence satisfies both checks, and a
|
|
23
21
|
* `#### ADR-001` shown as an example is counted as a real decision.
|
|
24
22
|
*/
|
|
25
|
-
function stripFencedCode(text) {
|
|
23
|
+
export function stripFencedCode(text) {
|
|
26
24
|
return text.replace(/^([ \t]*)(```|~~~)[\s\S]*?^\1\2[ \t]*$/gm, '');
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
export default { stripHtmlComments, stripFencedCode };
|
package/scripts/validate-cli.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* CLI entry for the agentic-architecture validator.
|
|
@@ -8,6 +7,6 @@
|
|
|
8
7
|
* `require.main` guard means requiring it never validates or exits, and it
|
|
9
8
|
* avoids a permanently-unreachable branch that blocks the 100% coverage gate.
|
|
10
9
|
*/
|
|
11
|
-
|
|
10
|
+
import { main } from './validate.js';
|
|
12
11
|
|
|
13
12
|
main();
|
package/scripts/validate.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { createReporter, readTextOrFail, walkMarkdown } from './validate/io.js';
|
|
7
|
+
import * as textHelpers from './validate/text.js';
|
|
8
|
+
import * as parityHelpers from './validate/parity.js';
|
|
9
|
+
import { parseAdrLedger, checkAdrIndexConsistency, checkGlossaryPopulated } from './validate/adr.js';
|
|
10
|
+
import { phaseRootConfig } from './validate/root.js';
|
|
11
|
+
import { phaseRules } from './validate/rules.js';
|
|
12
|
+
import { phaseSkills } from './validate/skills.js';
|
|
13
|
+
import { phaseLinks } from './validate/links.js';
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* NOTE: this module is a library. It never self-executes, so importing it from
|
|
@@ -123,7 +122,26 @@ function main(argv = process.argv.slice(2), exit = process.exit) {
|
|
|
123
122
|
return exit(result.errors === 0 ? 0 : 1);
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
|
|
125
|
+
const { evaluateParityTarget, isValidAgentsTarget, createLowercaseParityLink } = parityHelpers;
|
|
126
|
+
const { stripHtmlComments, stripFencedCode } = textHelpers;
|
|
127
|
+
|
|
128
|
+
export {
|
|
129
|
+
runValidation,
|
|
130
|
+
parseArgs,
|
|
131
|
+
createReporter,
|
|
132
|
+
main,
|
|
133
|
+
parseAdrLedger,
|
|
134
|
+
checkAdrIndexConsistency,
|
|
135
|
+
checkGlossaryPopulated,
|
|
136
|
+
evaluateParityTarget,
|
|
137
|
+
isValidAgentsTarget,
|
|
138
|
+
createLowercaseParityLink,
|
|
139
|
+
stripHtmlComments,
|
|
140
|
+
stripFencedCode,
|
|
141
|
+
walkMarkdown
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export default {
|
|
127
145
|
runValidation,
|
|
128
146
|
parseArgs,
|
|
129
147
|
createReporter,
|
|
@@ -131,10 +149,10 @@ module.exports = {
|
|
|
131
149
|
parseAdrLedger,
|
|
132
150
|
checkAdrIndexConsistency,
|
|
133
151
|
checkGlossaryPopulated,
|
|
134
|
-
evaluateParityTarget
|
|
135
|
-
isValidAgentsTarget
|
|
136
|
-
createLowercaseParityLink
|
|
137
|
-
stripHtmlComments
|
|
138
|
-
stripFencedCode
|
|
139
|
-
walkMarkdown
|
|
152
|
+
evaluateParityTarget,
|
|
153
|
+
isValidAgentsTarget,
|
|
154
|
+
createLowercaseParityLink,
|
|
155
|
+
stripHtmlComments,
|
|
156
|
+
stripFencedCode,
|
|
157
|
+
walkMarkdown
|
|
140
158
|
};
|
package/src/cli-parse.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument parsing for the azcodr CLI.
|
|
3
|
+
*
|
|
4
|
+
* Table-driven: every boolean flag is one Map entry, so adding a flag cannot
|
|
5
|
+
* raise the complexity of the parser itself.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface CliParsedOptions {
|
|
9
|
+
targetDir: string | null;
|
|
10
|
+
force: boolean;
|
|
11
|
+
noGit: boolean;
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
silent: boolean;
|
|
14
|
+
terminal: 'help' | 'version' | 'unknown-flag' | 'extra-arg' | null;
|
|
15
|
+
message?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type BooleanFlagKey = 'force' | 'noGit' | 'dryRun' | 'silent';
|
|
19
|
+
|
|
20
|
+
export function emptyOptions(): Omit<CliParsedOptions, 'terminal' | 'message'> {
|
|
21
|
+
return { targetDir: null, force: false, noGit: false, dryRun: false, silent: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const BOOLEAN_FLAGS = new Map<string, BooleanFlagKey>([
|
|
25
|
+
['-f', 'force'],
|
|
26
|
+
['--force', 'force'],
|
|
27
|
+
['--no-git', 'noGit'],
|
|
28
|
+
['-d', 'dryRun'],
|
|
29
|
+
['--dry-run', 'dryRun'],
|
|
30
|
+
['-s', 'silent'],
|
|
31
|
+
['--silent', 'silent']
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export interface ArgOutcome {
|
|
35
|
+
terminal: 'help' | 'version' | 'unknown-flag' | 'extra-arg' | null;
|
|
36
|
+
message?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function applyArg(
|
|
40
|
+
state: Omit<CliParsedOptions, 'terminal' | 'message'>,
|
|
41
|
+
arg: string
|
|
42
|
+
): ArgOutcome {
|
|
43
|
+
if (arg === '-h' || arg === '--help') return { terminal: 'help' };
|
|
44
|
+
if (arg === '-v' || arg === '--version') return { terminal: 'version' };
|
|
45
|
+
const flag = BOOLEAN_FLAGS.get(arg);
|
|
46
|
+
if (flag !== undefined) {
|
|
47
|
+
state[flag] = true;
|
|
48
|
+
return { terminal: null };
|
|
49
|
+
}
|
|
50
|
+
if (arg.startsWith('-')) {
|
|
51
|
+
return {
|
|
52
|
+
terminal: 'unknown-flag',
|
|
53
|
+
message: `Unknown argument '${arg}'. Run 'npx azcodr --help' for available options.`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (!state.targetDir) {
|
|
57
|
+
state.targetDir = arg;
|
|
58
|
+
return { terminal: null };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
terminal: 'extra-arg',
|
|
62
|
+
message: `Unexpected argument '${arg}'. Run 'npx azcodr --help' for available options.`
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseArgs(rawArgs: readonly string[]): CliParsedOptions {
|
|
67
|
+
const state = emptyOptions();
|
|
68
|
+
for (const arg of rawArgs) {
|
|
69
|
+
const outcome = applyArg(state, arg);
|
|
70
|
+
if (outcome.terminal !== null) {
|
|
71
|
+
return { ...state, terminal: outcome.terminal, message: outcome.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { ...state, terminal: null, message: undefined };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default { emptyOptions, BOOLEAN_FLAGS, applyArg, parseArgs };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { isProtectedTarget } from './guards.js';
|
|
6
|
+
|
|
7
|
+
export interface AskQuestionOptions {
|
|
8
|
+
input?: NodeJS.ReadableStream;
|
|
9
|
+
output?: NodeJS.WritableStream;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function askQuestion(
|
|
13
|
+
query: string,
|
|
14
|
+
{ input = process.stdin, output = process.stdout }: AskQuestionOptions = {}
|
|
15
|
+
): Promise<string> {
|
|
16
|
+
const rl = readline.createInterface({ input, output });
|
|
17
|
+
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let resolved = false;
|
|
20
|
+
rl.question(query, (answer: string) => {
|
|
21
|
+
if (!resolved) {
|
|
22
|
+
resolved = true;
|
|
23
|
+
rl.close();
|
|
24
|
+
resolve(answer.trim());
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
rl.on('close', () => {
|
|
28
|
+
if (!resolved) {
|
|
29
|
+
resolved = true;
|
|
30
|
+
resolve('');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PromptIo {
|
|
37
|
+
stdin: NodeJS.ReadStream | (NodeJS.ReadableStream & { isTTY?: boolean });
|
|
38
|
+
stdout: NodeJS.WriteStream | NodeJS.WritableStream;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function promptForTargetDir(io: PromptIo): Promise<string> {
|
|
42
|
+
const { stdin, stdout } = io;
|
|
43
|
+
if (!stdin.isTTY) return '.';
|
|
44
|
+
const answer = await askQuestion('? Where would you like to initialize your project? (./) ', {
|
|
45
|
+
input: stdin,
|
|
46
|
+
output: stdout
|
|
47
|
+
});
|
|
48
|
+
return answer || '.';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function rejectBadTarget(resolvedTarget: string, templateDir: string): string | null {
|
|
52
|
+
if (resolvedTarget === templateDir) {
|
|
53
|
+
return `Cannot scaffold into the template directory itself: ${resolvedTarget}`;
|
|
54
|
+
}
|
|
55
|
+
if (isProtectedTarget(resolvedTarget, { templateDir })) {
|
|
56
|
+
return `Refusing to scaffold into protected directory: ${resolvedTarget}. Choose a project subdirectory instead.`;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ResolveTargetIo extends PromptIo {
|
|
62
|
+
cwd: string;
|
|
63
|
+
templateDir: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ResolveTargetResult {
|
|
67
|
+
resolvedTarget: string;
|
|
68
|
+
chosen: string;
|
|
69
|
+
error: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function resolveTargetDir(
|
|
73
|
+
targetDir: string | null,
|
|
74
|
+
io: ResolveTargetIo
|
|
75
|
+
): Promise<ResolveTargetResult> {
|
|
76
|
+
const { cwd, templateDir } = io;
|
|
77
|
+
const chosen = targetDir || (await promptForTargetDir(io));
|
|
78
|
+
const resolvedTarget = path.resolve(cwd, chosen);
|
|
79
|
+
const rejection = rejectBadTarget(resolvedTarget, templateDir);
|
|
80
|
+
if (rejection !== null) return { resolvedTarget, chosen, error: rejection };
|
|
81
|
+
return { resolvedTarget, chosen, error: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type TargetDirectoryStatus = 'missing' | 'not-a-directory' | 'empty' | 'non-empty';
|
|
85
|
+
|
|
86
|
+
export interface TargetStatusResult {
|
|
87
|
+
status: TargetDirectoryStatus;
|
|
88
|
+
count: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function targetStatus(resolvedTarget: string): TargetStatusResult {
|
|
92
|
+
if (!fs.existsSync(resolvedTarget)) return { status: 'missing', count: 0 };
|
|
93
|
+
if (!fs.statSync(resolvedTarget).isDirectory()) return { status: 'not-a-directory', count: 0 };
|
|
94
|
+
const count = fs.readdirSync(resolvedTarget).length;
|
|
95
|
+
return { status: count === 0 ? 'empty' : 'non-empty', count };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ConfirmOverwriteIo extends PromptIo {
|
|
99
|
+
out: (msg: string) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ConfirmOverwriteResult {
|
|
103
|
+
confirmed: boolean;
|
|
104
|
+
aborted: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function confirmOverwrite(
|
|
108
|
+
targetDir: string,
|
|
109
|
+
count: number,
|
|
110
|
+
io: ConfirmOverwriteIo
|
|
111
|
+
): Promise<ConfirmOverwriteResult> {
|
|
112
|
+
const { stdin, stdout, out } = io;
|
|
113
|
+
if (!stdin.isTTY) return { confirmed: false, aborted: false };
|
|
114
|
+
const confirm = await askQuestion(
|
|
115
|
+
`⚠️ Target directory '${targetDir}' is not empty (${count} items). Continue? (y/N) `,
|
|
116
|
+
{ input: stdin, output: stdout }
|
|
117
|
+
);
|
|
118
|
+
if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') {
|
|
119
|
+
out('Scaffolding aborted.');
|
|
120
|
+
return { confirmed: false, aborted: true };
|
|
121
|
+
}
|
|
122
|
+
return { confirmed: true, aborted: false };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface EnsureWritableOptions {
|
|
126
|
+
chosen: string;
|
|
127
|
+
resolvedTarget: string;
|
|
128
|
+
force: boolean;
|
|
129
|
+
io: ConfirmOverwriteIo & { err: (msg: string) => void };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface EnsureWritableResult {
|
|
133
|
+
force?: boolean;
|
|
134
|
+
exitCode?: number;
|
|
135
|
+
message?: string | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Ensures the target may be written. Returns { force } on success or
|
|
140
|
+
* { exitCode, message? } when the CLI must stop before scaffolding.
|
|
141
|
+
*/
|
|
142
|
+
export async function ensureWritableTarget(
|
|
143
|
+
options: EnsureWritableOptions
|
|
144
|
+
): Promise<EnsureWritableResult> {
|
|
145
|
+
const { chosen, resolvedTarget, force, io } = options;
|
|
146
|
+
const { err } = io;
|
|
147
|
+
const { status, count } = targetStatus(resolvedTarget);
|
|
148
|
+
if (status === 'missing' || status === 'empty' || force) return { force };
|
|
149
|
+
if (status === 'not-a-directory') {
|
|
150
|
+
return { exitCode: 1, message: `Target '${resolvedTarget}' already exists and is not a directory.` };
|
|
151
|
+
}
|
|
152
|
+
const { confirmed, aborted } = await confirmOverwrite(chosen, count, io);
|
|
153
|
+
if (aborted) return { exitCode: 0, message: null };
|
|
154
|
+
if (confirmed) return { force: true };
|
|
155
|
+
err(`Target directory '${resolvedTarget}' is not empty. Use --force to proceed.`);
|
|
156
|
+
return { exitCode: 1, message: null };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default {
|
|
160
|
+
askQuestion,
|
|
161
|
+
promptForTargetDir,
|
|
162
|
+
rejectBadTarget,
|
|
163
|
+
resolveTargetDir,
|
|
164
|
+
targetStatus,
|
|
165
|
+
confirmOverwrite,
|
|
166
|
+
ensureWritableTarget
|
|
167
|
+
};
|