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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { scaffold, getTemplateDir } from './scaffold.js';
|
|
6
|
+
import type { ScaffoldOptions, ScaffoldResult } from './scaffold.js';
|
|
7
|
+
import { parseArgs } from './cli-parse.js';
|
|
8
|
+
import type { CliParsedOptions } from './cli-parse.js';
|
|
9
|
+
import { askQuestion, resolveTargetDir, ensureWritableTarget } from './cli-target.js';
|
|
10
|
+
|
|
11
|
+
interface PackageJsonShape {
|
|
12
|
+
version: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const pkg: PackageJsonShape = JSON.parse(
|
|
16
|
+
fs.readFileSync(
|
|
17
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
|
|
18
|
+
'utf-8'
|
|
19
|
+
)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export function printHelp(out: (msg: string) => void = console.log): void {
|
|
23
|
+
out(`
|
|
24
|
+
azcodr v${pkg.version}
|
|
25
|
+
Enterprise Multi-Tenant Architecture & Agentic Engineering Starter Template
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
npx azcodr [directory] [options]
|
|
29
|
+
|
|
30
|
+
Commands:
|
|
31
|
+
[directory] Scaffold azcodr template into directory (default: current directory)
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-d, --dry-run Simulate scaffolding without modifying filesystem
|
|
35
|
+
-s, --silent Suppress console output messages
|
|
36
|
+
-f, --force Overwrite existing files in target directory without confirmation
|
|
37
|
+
--no-git Do not initialize a git repository
|
|
38
|
+
-v, --version Display version number
|
|
39
|
+
-h, --help Display this help message
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
npx azcodr my-project
|
|
43
|
+
npx azcodr . --dry-run
|
|
44
|
+
npx azcodr . --force
|
|
45
|
+
`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function printVersion(out: (msg: string) => void = console.log): void {
|
|
49
|
+
out(pkg.version);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CliIo {
|
|
53
|
+
out?: (msg: string) => void;
|
|
54
|
+
err?: (msg: string) => void;
|
|
55
|
+
exit?: (code: number) => void | number;
|
|
56
|
+
stdin?: NodeJS.ReadableStream & { isTTY?: boolean };
|
|
57
|
+
stdout?: NodeJS.WritableStream;
|
|
58
|
+
cwd?: string;
|
|
59
|
+
templateDir?: string;
|
|
60
|
+
scaffold?: (options?: ScaffoldOptions) => ScaffoldResult;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface NormalizedCliIo {
|
|
64
|
+
out: (msg: string) => void;
|
|
65
|
+
err: (msg: string) => void;
|
|
66
|
+
exit: (code: number) => void | number;
|
|
67
|
+
stdin: NodeJS.ReadableStream & { isTTY?: boolean };
|
|
68
|
+
stdout: NodeJS.WritableStream;
|
|
69
|
+
cwd: string;
|
|
70
|
+
templateDir: string;
|
|
71
|
+
scaffoldFn: (options?: ScaffoldOptions) => ScaffoldResult;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeIo(io: CliIo = {}): NormalizedCliIo {
|
|
75
|
+
const {
|
|
76
|
+
out = console.log,
|
|
77
|
+
err = console.error,
|
|
78
|
+
exit = process.exit,
|
|
79
|
+
stdin = process.stdin,
|
|
80
|
+
stdout = process.stdout,
|
|
81
|
+
cwd = process.cwd(),
|
|
82
|
+
templateDir = getTemplateDir(),
|
|
83
|
+
scaffold: scaffoldFn = scaffold
|
|
84
|
+
} = io;
|
|
85
|
+
return { out, err, exit, stdin, stdout, cwd, templateDir, scaffoldFn };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function outBanner(fullIo: NormalizedCliIo): void {
|
|
89
|
+
fullIo.out('\n🚀 azcodr - Enterprise Multi-Tenant Architecture & Agentic Engineering\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleTerminal(parsed: CliParsedOptions, io: NormalizedCliIo): void | number {
|
|
93
|
+
const { out, err, exit } = io;
|
|
94
|
+
if (parsed.terminal === 'help') {
|
|
95
|
+
printHelp(out);
|
|
96
|
+
return exit(0);
|
|
97
|
+
}
|
|
98
|
+
if (parsed.terminal === 'version') {
|
|
99
|
+
printVersion(out);
|
|
100
|
+
return exit(0);
|
|
101
|
+
}
|
|
102
|
+
err(`❌ Error: ${parsed.message}`);
|
|
103
|
+
return exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reportDryRun(result: ScaffoldResult, out: (msg: string) => void): void {
|
|
107
|
+
for (const action of result.actions) {
|
|
108
|
+
out(` [preview] ${action}`);
|
|
109
|
+
}
|
|
110
|
+
out('\n🎉 Dry run completed. 0 files modified on disk.\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function reportSuccess(result: ScaffoldResult, targetDir: string | null, out: (msg: string) => void): void {
|
|
114
|
+
out(' ✅ Progressive disclosure rules copied (docs/rules/)');
|
|
115
|
+
out(' ✅ Workspace knowledge hub and ADR ledger copied (docs/knowledge/, memory.md)');
|
|
116
|
+
out(' ✅ Specialized agentic skills copied (.agents/skills/)');
|
|
117
|
+
out(' ✅ Editor formatting standards initialized (.editorconfig)');
|
|
118
|
+
out(' ✅ Agent directives and harness symlinks established (AGENTS.md, CLAUDE.md, agents.md, GEMINI.md, .cursorrules, .windsurfrules, .github/copilot-instructions.md)');
|
|
119
|
+
out(' ✅ Project configuration initialized (package.json)');
|
|
120
|
+
if (result.gitInitialized) {
|
|
121
|
+
out(' ✅ Git repository initialized');
|
|
122
|
+
}
|
|
123
|
+
out('\n🎉 azcodr initialized successfully!\n');
|
|
124
|
+
out('Next steps:');
|
|
125
|
+
let step = 1;
|
|
126
|
+
if (targetDir !== '.' && targetDir !== './') {
|
|
127
|
+
out(` ${step++}. cd ${targetDir}`);
|
|
128
|
+
}
|
|
129
|
+
out(` ${step++}. Open the project in your AI coding assistant (Antigravity, Claude Code, Cursor, OpenHands)`);
|
|
130
|
+
out(` ${step++}. Run /lets-build to start the architectural interview and scaffold your application stack!\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function runScaffold(resolvedTarget: string, parsed: CliParsedOptions, io: NormalizedCliIo): void | number {
|
|
134
|
+
const { out, err, exit, templateDir, scaffoldFn } = io;
|
|
135
|
+
const banner = parsed.dryRun
|
|
136
|
+
? `🔍 DRY RUN: Simulating azcodr scaffolding into: ${resolvedTarget}\n`
|
|
137
|
+
: `📦 Scaffolding azcodr into: ${resolvedTarget}`;
|
|
138
|
+
if (!parsed.silent) out(banner);
|
|
139
|
+
try {
|
|
140
|
+
const result = scaffoldFn({
|
|
141
|
+
targetDir: resolvedTarget,
|
|
142
|
+
force: parsed.force,
|
|
143
|
+
noGit: parsed.noGit,
|
|
144
|
+
templateDir,
|
|
145
|
+
dryRun: parsed.dryRun,
|
|
146
|
+
silent: parsed.silent
|
|
147
|
+
});
|
|
148
|
+
if (!parsed.silent) {
|
|
149
|
+
if (parsed.dryRun) reportDryRun(result, out);
|
|
150
|
+
else reportSuccess(result, parsed.targetDir, out);
|
|
151
|
+
}
|
|
152
|
+
return exit(0);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
err(`\n❌ Scaffolding failed: ${(error as Error).message}\n`);
|
|
155
|
+
return exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface ResolvePhaseResult {
|
|
160
|
+
resolvedTarget?: string;
|
|
161
|
+
chosen?: string;
|
|
162
|
+
exitCode?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function resolvePhase(parsed: CliParsedOptions, fullIo: NormalizedCliIo): Promise<ResolvePhaseResult> {
|
|
166
|
+
const { resolvedTarget, chosen, error } = await resolveTargetDir(parsed.targetDir, fullIo);
|
|
167
|
+
if (error !== null) {
|
|
168
|
+
fullIo.err(`❌ Error: ${error}`);
|
|
169
|
+
return { exitCode: 1 };
|
|
170
|
+
}
|
|
171
|
+
return { resolvedTarget, chosen };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface WritablePhaseResult {
|
|
175
|
+
force?: boolean;
|
|
176
|
+
exitCode?: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function writablePhase(
|
|
180
|
+
target: { chosen: string; resolvedTarget: string },
|
|
181
|
+
force: boolean,
|
|
182
|
+
fullIo: NormalizedCliIo
|
|
183
|
+
): Promise<WritablePhaseResult> {
|
|
184
|
+
const writable = await ensureWritableTarget({
|
|
185
|
+
chosen: target.chosen,
|
|
186
|
+
resolvedTarget: target.resolvedTarget,
|
|
187
|
+
force,
|
|
188
|
+
io: fullIo
|
|
189
|
+
});
|
|
190
|
+
if (writable.exitCode === undefined) return { force: writable.force };
|
|
191
|
+
if (writable.message) {
|
|
192
|
+
fullIo.err(`❌ Error: ${writable.message}`);
|
|
193
|
+
}
|
|
194
|
+
return { exitCode: writable.exitCode };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function runCli(
|
|
198
|
+
rawArgs: string[] = process.argv.slice(2),
|
|
199
|
+
io: CliIo = {}
|
|
200
|
+
): Promise<void | number> {
|
|
201
|
+
const fullIo = normalizeIo(io);
|
|
202
|
+
|
|
203
|
+
const parsed = parseArgs(rawArgs);
|
|
204
|
+
if (parsed.terminal !== null) return handleTerminal(parsed, fullIo);
|
|
205
|
+
|
|
206
|
+
if (!parsed.silent) {
|
|
207
|
+
outBanner(fullIo);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const target = await resolvePhase(parsed, fullIo);
|
|
211
|
+
if (target.exitCode !== undefined) return fullIo.exit(target.exitCode);
|
|
212
|
+
|
|
213
|
+
const ready = await writablePhase(
|
|
214
|
+
target as { chosen: string; resolvedTarget: string },
|
|
215
|
+
parsed.force,
|
|
216
|
+
fullIo
|
|
217
|
+
);
|
|
218
|
+
if (ready.exitCode !== undefined) return fullIo.exit(ready.exitCode);
|
|
219
|
+
|
|
220
|
+
return runScaffold(target.resolvedTarget!, { ...parsed, force: ready.force! }, fullIo);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function main(): Promise<void> {
|
|
224
|
+
try {
|
|
225
|
+
await runCli(process.argv.slice(2));
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.error('Unexpected error:', err);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export { askQuestion };
|
|
233
|
+
|
|
234
|
+
export default {
|
|
235
|
+
runCli,
|
|
236
|
+
main,
|
|
237
|
+
printHelp,
|
|
238
|
+
printVersion,
|
|
239
|
+
askQuestion
|
|
240
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-readable failure codes.
|
|
3
|
+
*
|
|
4
|
+
* Consumers branch on `err.code`, never on message text: a wording change must
|
|
5
|
+
* never be a breaking change (learned from typed-settings, where `docs/errors.md`
|
|
6
|
+
* explicitly tells users not to regex the message). Pinned by
|
|
7
|
+
* tests/error-codes.test.js.
|
|
8
|
+
*/
|
|
9
|
+
export const ERROR_CODES = {
|
|
10
|
+
E_TARGET_IS_TEMPLATE: 'Cannot scaffold into the azcodr template directory itself',
|
|
11
|
+
E_TARGET_NOT_EMPTY: 'Target directory is not empty',
|
|
12
|
+
E_TARGET_IS_PROTECTED: 'Refusing to scaffold into a protected system directory',
|
|
13
|
+
E_GIT_ARGS_INVALID: 'runGit requires a non-empty argv array',
|
|
14
|
+
E_GIT_BLOCKED: 'Blocked git subcommand',
|
|
15
|
+
E_PATH_ESCAPE: 'Path escapes allowed root'
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export type ScaffoldErrorCode = keyof typeof ERROR_CODES;
|
|
19
|
+
|
|
20
|
+
export interface ScaffoldErrorShape extends Error {
|
|
21
|
+
name: 'ScaffoldError';
|
|
22
|
+
code: ScaffoldErrorCode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ScaffoldError extends Error implements ScaffoldErrorShape {
|
|
26
|
+
override readonly name: 'ScaffoldError' = 'ScaffoldError';
|
|
27
|
+
readonly code: ScaffoldErrorCode;
|
|
28
|
+
|
|
29
|
+
constructor(code: ScaffoldErrorCode, message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default { ERROR_CODES, ScaffoldError };
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import cp from 'node:child_process';
|
|
2
|
+
import { ScaffoldError, ERROR_CODES } from './errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Socket-hardened process boundary (Supply Chain: shell access).
|
|
6
|
+
* Scaffolder must spawn `git`, but never via a shell string.
|
|
7
|
+
* Uses execFileSync with argv (shell:false) and an explicit subcommand allowlist.
|
|
8
|
+
* No network, no env exfiltration; cwd is constrained to targetDir callers.
|
|
9
|
+
*/
|
|
10
|
+
export const GIT_ALLOWED_SUBCOMMANDS = new Set([
|
|
11
|
+
'rev-parse',
|
|
12
|
+
'init',
|
|
13
|
+
'branch',
|
|
14
|
+
'add',
|
|
15
|
+
'commit'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export function runGit(args: string[], options: cp.ExecFileSyncOptions = {}): string {
|
|
19
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
20
|
+
throw new ScaffoldError('E_GIT_ARGS_INVALID', ERROR_CODES.E_GIT_ARGS_INVALID);
|
|
21
|
+
}
|
|
22
|
+
const subcommand = args[0];
|
|
23
|
+
if (!subcommand || !GIT_ALLOWED_SUBCOMMANDS.has(subcommand)) {
|
|
24
|
+
throw new ScaffoldError('E_GIT_BLOCKED', `${ERROR_CODES.E_GIT_BLOCKED}: ${String(subcommand)}`);
|
|
25
|
+
}
|
|
26
|
+
return cp.execFileSync('git', args, {
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
stdio: 'pipe',
|
|
29
|
+
shell: false,
|
|
30
|
+
...options
|
|
31
|
+
}) as string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default { GIT_ALLOWED_SUBCOMMANDS, runGit };
|
package/src/guards.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { ScaffoldError, ERROR_CODES } from './errors.js';
|
|
6
|
+
import { getTemplateDir } from './scaffold.js';
|
|
7
|
+
|
|
8
|
+
export interface ProtectedTargetOptions {
|
|
9
|
+
templateDir?: string;
|
|
10
|
+
force?: boolean;
|
|
11
|
+
dryRun?: boolean;
|
|
12
|
+
allowProtected?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Filesystem scope guard (Supply Chain: filesystem access).
|
|
17
|
+
* Constrains all reads/writes to targetDir / templateDir.
|
|
18
|
+
*/
|
|
19
|
+
export function assertInside(root: string, candidate: string, message?: string): void {
|
|
20
|
+
const resolvedRoot = path.resolve(root);
|
|
21
|
+
const resolvedCandidate = path.resolve(root, candidate);
|
|
22
|
+
const relative = path.relative(resolvedRoot, resolvedCandidate);
|
|
23
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
24
|
+
throw new ScaffoldError('E_PATH_ESCAPE', message || `${ERROR_CODES.E_PATH_ESCAPE}: ${candidate}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes a path for protected-target comparison. On Windows the filesystem
|
|
30
|
+
* is case-insensitive, so `C:\Users\Name` and `c:\users\name` are the same
|
|
31
|
+
* directory and must compare equal.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeForComparison(p: string): string {
|
|
34
|
+
const resolved = path.resolve(p);
|
|
35
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readHomeDir(): string {
|
|
39
|
+
try {
|
|
40
|
+
return os.homedir();
|
|
41
|
+
} catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isFilesystemRoot(resolvedTarget: string): boolean {
|
|
47
|
+
return resolvedTarget === path.parse(resolvedTarget).root;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isHomeOrParent(normalizedTarget: string, home: string): boolean {
|
|
51
|
+
if (!home) return false;
|
|
52
|
+
if (normalizedTarget === normalizeForComparison(home)) return true;
|
|
53
|
+
const parent = path.dirname(path.resolve(home));
|
|
54
|
+
return normalizedTarget === normalizeForComparison(parent);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isTemplateAncestor(resolvedTarget: string, templateDir: string): boolean {
|
|
58
|
+
const resolvedTemplate = path.resolve(templateDir);
|
|
59
|
+
const relative = path.relative(resolvedTarget, resolvedTemplate);
|
|
60
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolvesToProtectedLocation(resolvedTarget: string, home: string): boolean {
|
|
64
|
+
// Symlink alias: a symlink to / or ~ must not bypass the lexical checks.
|
|
65
|
+
let real = '';
|
|
66
|
+
try {
|
|
67
|
+
if (!fs.existsSync(resolvedTarget)) return false;
|
|
68
|
+
real = fs.realpathSync(resolvedTarget);
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (real === path.parse(real).root) return true;
|
|
73
|
+
return Boolean(home) && normalizeForComparison(real) === normalizeForComparison(home);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns true when `targetDir` is a location the scaffolder must never write
|
|
78
|
+
* into: the filesystem root, the user's home directory, the home directory's
|
|
79
|
+
* parent (e.g. /home, C:\Users -- scaffolding there affects every user), or an
|
|
80
|
+
* ancestor of the template itself (scaffolding into D:\projects would merge
|
|
81
|
+
* the template into its own parent).
|
|
82
|
+
*
|
|
83
|
+
* Lexical comparison is not enough: a symlink pointing at home must also be
|
|
84
|
+
* caught, so an existing target is resolved with realpath before comparing.
|
|
85
|
+
*/
|
|
86
|
+
export function isProtectedTarget(targetDir: string, options: ProtectedTargetOptions = {}): boolean {
|
|
87
|
+
const resolvedTarget = path.resolve(targetDir);
|
|
88
|
+
const normalizedTarget = normalizeForComparison(resolvedTarget);
|
|
89
|
+
if (isFilesystemRoot(resolvedTarget)) return true;
|
|
90
|
+
const home = readHomeDir();
|
|
91
|
+
if (isHomeOrParent(normalizedTarget, home)) return true;
|
|
92
|
+
const { templateDir = getTemplateDir() } = options;
|
|
93
|
+
if (isTemplateAncestor(resolvedTarget, templateDir)) return true;
|
|
94
|
+
return resolvesToProtectedLocation(resolvedTarget, home);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default {
|
|
98
|
+
assertInside,
|
|
99
|
+
normalizeForComparison,
|
|
100
|
+
isProtectedTarget
|
|
101
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ScaffoldErrorCode =
|
|
2
|
+
| 'E_TARGET_IS_TEMPLATE'
|
|
3
|
+
| 'E_TARGET_NOT_EMPTY'
|
|
4
|
+
| 'E_TARGET_IS_PROTECTED'
|
|
5
|
+
| 'E_GIT_ARGS_INVALID'
|
|
6
|
+
| 'E_GIT_BLOCKED'
|
|
7
|
+
| 'E_PATH_ESCAPE';
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
scaffold,
|
|
11
|
+
validateTarget,
|
|
12
|
+
copyTemplate,
|
|
13
|
+
ensureSymlink,
|
|
14
|
+
ensureSymlinkOrPointer,
|
|
15
|
+
isSameCaseInsensitiveFile,
|
|
16
|
+
makeScriptsExecutable,
|
|
17
|
+
initGit,
|
|
18
|
+
isInsideGitWorkTree,
|
|
19
|
+
runGit,
|
|
20
|
+
assertInside,
|
|
21
|
+
isProtectedTarget,
|
|
22
|
+
getTemplateDir,
|
|
23
|
+
TEMPLATE_ITEMS,
|
|
24
|
+
ScaffoldError,
|
|
25
|
+
ERROR_CODES
|
|
26
|
+
} from './scaffold.js';
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
ScaffoldOptions,
|
|
30
|
+
ScaffoldResult,
|
|
31
|
+
ValidateTargetOptions,
|
|
32
|
+
CopyTemplateOptions,
|
|
33
|
+
EnsureSymlinkOptions,
|
|
34
|
+
InitGitOptions,
|
|
35
|
+
ScaffoldErrorShape
|
|
36
|
+
} from './scaffold.js';
|
|
37
|
+
|
|
38
|
+
import scaffoldModule from './scaffold.js';
|
|
39
|
+
export default scaffoldModule;
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface EnsureSymlinkOptions {
|
|
5
|
+
targetDir: string;
|
|
6
|
+
linkName: string;
|
|
7
|
+
targetFileName: string;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface BothEntriesExistOptions {
|
|
12
|
+
targetDir: string;
|
|
13
|
+
linkName: string;
|
|
14
|
+
targetFileName: string;
|
|
15
|
+
entries: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function namesDifferOnlyByCase(linkName: string, targetFileName: string): boolean {
|
|
19
|
+
if (linkName.toLowerCase() !== targetFileName.toLowerCase()) return false;
|
|
20
|
+
return linkName !== targetFileName;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readDirEntries(targetDir: string): string[] | null {
|
|
24
|
+
try {
|
|
25
|
+
return fs.readdirSync(targetDir);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bothEntriesExist(options: BothEntriesExistOptions): boolean {
|
|
32
|
+
const { targetDir, linkName, targetFileName, entries } = options;
|
|
33
|
+
if (!entries.includes(targetFileName) || !entries.includes(linkName)) return false;
|
|
34
|
+
try {
|
|
35
|
+
fs.lstatSync(path.join(targetDir, targetFileName));
|
|
36
|
+
fs.lstatSync(path.join(targetDir, linkName));
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function accessiblePair(targetPath: string, linkPath: string): boolean {
|
|
44
|
+
if (!fs.existsSync(targetPath) || !fs.existsSync(linkPath)) return false;
|
|
45
|
+
try {
|
|
46
|
+
fs.lstatSync(targetPath);
|
|
47
|
+
fs.lstatSync(linkPath);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function removeIfExists(linkPath: string): void {
|
|
55
|
+
try {
|
|
56
|
+
fs.lstatSync(linkPath);
|
|
57
|
+
fs.rmSync(linkPath, { force: true });
|
|
58
|
+
} catch {
|
|
59
|
+
// Path does not exist, proceed
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function copyFallback(targetDir: string, linkName: string, targetFileName: string): void {
|
|
64
|
+
const sourceFile = path.resolve(targetDir, targetFileName);
|
|
65
|
+
if (fs.existsSync(sourceFile)) {
|
|
66
|
+
fs.copyFileSync(sourceFile, path.join(targetDir, linkName));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Detects whether linkName and targetFileName refer to the same entry on a case-insensitive filesystem.
|
|
72
|
+
* Uses exact directory listing (not existsSync, which lies on case-insensitive systems).
|
|
73
|
+
*/
|
|
74
|
+
export function isSameCaseInsensitiveFile(
|
|
75
|
+
targetDir: string,
|
|
76
|
+
linkName: string,
|
|
77
|
+
targetFileName: string
|
|
78
|
+
): boolean {
|
|
79
|
+
if (linkName === targetFileName) return true;
|
|
80
|
+
if (!namesDifferOnlyByCase(linkName, targetFileName)) return false;
|
|
81
|
+
const entries = readDirEntries(targetDir);
|
|
82
|
+
if (entries === null) return false;
|
|
83
|
+
if (bothEntriesExist({ targetDir, linkName, targetFileName, entries })) {
|
|
84
|
+
return !fs.lstatSync(path.join(targetDir, linkName)).isSymbolicLink();
|
|
85
|
+
}
|
|
86
|
+
const targetPath = path.join(targetDir, targetFileName);
|
|
87
|
+
const linkPath = path.join(targetDir, linkName);
|
|
88
|
+
return accessiblePair(targetPath, linkPath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Safely creates or updates a symbolic link, falling back to a file copy if symlinks are unsupported.
|
|
93
|
+
*/
|
|
94
|
+
export function ensureSymlink(options: EnsureSymlinkOptions): boolean {
|
|
95
|
+
const { targetDir, linkName, targetFileName, dryRun = false } = options;
|
|
96
|
+
if (dryRun) return true;
|
|
97
|
+
if (isSameCaseInsensitiveFile(targetDir, linkName, targetFileName)) return true;
|
|
98
|
+
const linkPath = path.join(targetDir, linkName);
|
|
99
|
+
removeIfExists(linkPath);
|
|
100
|
+
try {
|
|
101
|
+
fs.symlinkSync(targetFileName, linkPath, 'file');
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
copyFallback(targetDir, linkName, targetFileName);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Creates a symlink, falling back to a text pointer (not a full copy).
|
|
111
|
+
* Used for .github/copilot-instructions.md where a full AGENTS.md copy would
|
|
112
|
+
* break relative markdown links (they resolve from .github/, not root).
|
|
113
|
+
*/
|
|
114
|
+
export function ensureSymlinkOrPointer(options: EnsureSymlinkOptions): boolean {
|
|
115
|
+
const { targetDir, linkName, targetFileName, dryRun = false } = options;
|
|
116
|
+
if (dryRun) return true;
|
|
117
|
+
const linkPath = path.join(targetDir, linkName);
|
|
118
|
+
try {
|
|
119
|
+
try {
|
|
120
|
+
fs.lstatSync(linkPath);
|
|
121
|
+
const existing = fs.existsSync(linkPath) ? fs.readFileSync(linkPath, 'utf-8') : '';
|
|
122
|
+
if (existing.trim() === targetFileName) return true;
|
|
123
|
+
fs.rmSync(linkPath, { force: true });
|
|
124
|
+
} catch {
|
|
125
|
+
// Path does not exist, proceed
|
|
126
|
+
}
|
|
127
|
+
fs.symlinkSync(targetFileName, linkPath, 'file');
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
fs.writeFileSync(linkPath, `${targetFileName}\n`, 'utf-8');
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default {
|
|
136
|
+
isSameCaseInsensitiveFile,
|
|
137
|
+
ensureSymlink,
|
|
138
|
+
ensureSymlinkOrPointer
|
|
139
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function chmodShFile(filePath: string, dryRun: boolean): void {
|
|
5
|
+
if (dryRun) return;
|
|
6
|
+
try {
|
|
7
|
+
fs.chmodSync(filePath, 0o755);
|
|
8
|
+
} catch {
|
|
9
|
+
// Non-critical if filesystem does not support POSIX permissions
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function chmodShFilesIn(scriptsDir: string, dryRun: boolean): string[] {
|
|
14
|
+
const modified: string[] = [];
|
|
15
|
+
if (!fs.existsSync(scriptsDir) || !fs.statSync(scriptsDir).isDirectory()) return modified;
|
|
16
|
+
const files = fs.readdirSync(scriptsDir);
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
if (!file.endsWith('.sh')) continue;
|
|
19
|
+
const filePath = path.join(scriptsDir, file);
|
|
20
|
+
modified.push(filePath);
|
|
21
|
+
chmodShFile(filePath, dryRun);
|
|
22
|
+
}
|
|
23
|
+
return modified;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ensures all bash scripts in agent and skill directories have executable permissions (0o755).
|
|
28
|
+
*/
|
|
29
|
+
export function makeScriptsExecutable(targetDir: string, dryRun = false): string[] {
|
|
30
|
+
const agentScriptsDir = path.join(targetDir, '.agents', 'scripts');
|
|
31
|
+
const modified = chmodShFilesIn(agentScriptsDir, dryRun);
|
|
32
|
+
const skillsDir = path.join(targetDir, '.agents', 'skills');
|
|
33
|
+
if (!fs.existsSync(skillsDir)) return modified;
|
|
34
|
+
const skills = fs.readdirSync(skillsDir);
|
|
35
|
+
for (const skill of skills) {
|
|
36
|
+
const scriptsDir = path.join(skillsDir, skill, 'scripts');
|
|
37
|
+
modified.push(...chmodShFilesIn(scriptsDir, dryRun));
|
|
38
|
+
}
|
|
39
|
+
return modified;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default { makeScriptsExecutable };
|