azcodr 2.0.0 → 2.2.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.
Files changed (73) hide show
  1. package/.agents/hooks.json +3 -3
  2. package/.github/workflows/ci.yml +1 -0
  3. package/.github/workflows/publish.yml +0 -4
  4. package/AGENTS.md +1 -1
  5. package/bin/azcodr.js +11 -4
  6. package/lib/cli-parse.d.ts +32 -0
  7. package/lib/cli-parse.d.ts.map +1 -0
  8. package/lib/cli-parse.js +45 -41
  9. package/lib/cli-parse.js.map +1 -0
  10. package/lib/cli-target.d.ts +66 -0
  11. package/lib/cli-target.d.ts.map +1 -0
  12. package/lib/cli-target.js +89 -96
  13. package/lib/cli-target.js.map +1 -0
  14. package/lib/cli.d.ts +40 -0
  15. package/lib/cli.d.ts.map +1 -0
  16. package/lib/cli.js +122 -136
  17. package/lib/cli.js.map +1 -0
  18. package/lib/errors.d.ts +54 -0
  19. package/lib/errors.d.ts.map +1 -0
  20. package/lib/errors.js +25 -18
  21. package/lib/errors.js.map +1 -0
  22. package/lib/git.d.ts +23 -0
  23. package/lib/git.d.ts.map +1 -0
  24. package/lib/git.js +33 -22
  25. package/lib/git.js.map +1 -0
  26. package/lib/guards.d.ts +50 -0
  27. package/lib/guards.d.ts.map +1 -0
  28. package/lib/guards.js +75 -64
  29. package/lib/guards.js.map +1 -0
  30. package/lib/index.d.ts +14 -188
  31. package/lib/index.d.ts.map +1 -0
  32. package/lib/index.js +17 -5
  33. package/lib/index.js.map +1 -0
  34. package/lib/links.d.ts +42 -0
  35. package/lib/links.d.ts.map +1 -0
  36. package/lib/links.js +116 -99
  37. package/lib/links.js.map +1 -0
  38. package/lib/permissions.d.ts +13 -0
  39. package/lib/permissions.d.ts.map +1 -0
  40. package/lib/permissions.js +40 -36
  41. package/lib/permissions.js.map +1 -0
  42. package/lib/repo.d.ts +30 -0
  43. package/lib/repo.d.ts.map +1 -0
  44. package/lib/repo.js +80 -71
  45. package/lib/repo.js.map +1 -0
  46. package/lib/scaffold.d.ts +109 -0
  47. package/lib/scaffold.d.ts.map +1 -0
  48. package/lib/scaffold.js +165 -197
  49. package/lib/scaffold.js.map +1 -0
  50. package/memory.md +24 -0
  51. package/package.json +14 -5
  52. package/scripts/validate/adr.js +11 -7
  53. package/scripts/validate/io.js +7 -9
  54. package/scripts/validate/links.js +6 -7
  55. package/scripts/validate/parity.js +5 -7
  56. package/scripts/validate/root.js +6 -7
  57. package/scripts/validate/rules.js +7 -9
  58. package/scripts/validate/skills.js +8 -10
  59. package/scripts/validate/text.js +3 -5
  60. package/scripts/validate-cli.js +1 -2
  61. package/scripts/validate.js +37 -19
  62. package/src/cli-parse.ts +77 -0
  63. package/src/cli-target.ts +167 -0
  64. package/src/cli.ts +240 -0
  65. package/src/errors.ts +50 -0
  66. package/src/git.ts +42 -0
  67. package/src/guards.ts +116 -0
  68. package/src/index.ts +60 -0
  69. package/src/links.ts +153 -0
  70. package/src/permissions.ts +46 -0
  71. package/src/repo.ts +104 -0
  72. package/src/scaffold.ts +282 -0
  73. package/scripts/test_coverage.js +0 -66
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,50 @@
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
+ /**
19
+ * Union of valid machine-readable error codes for scaffolding failures.
20
+ */
21
+ export type ScaffoldErrorCode = keyof typeof ERROR_CODES;
22
+
23
+ /**
24
+ * Structural interface contract for errors thrown by the scaffolder.
25
+ */
26
+ export interface ScaffoldErrorShape extends Error {
27
+ name: 'ScaffoldError';
28
+ code: ScaffoldErrorCode;
29
+ }
30
+
31
+ /**
32
+ * Custom error class thrown by scaffolding operations, carrying a machine-readable code.
33
+ */
34
+ export class ScaffoldError extends Error implements ScaffoldErrorShape {
35
+ override readonly name: 'ScaffoldError' = 'ScaffoldError';
36
+ readonly code: ScaffoldErrorCode;
37
+
38
+ /**
39
+ * Constructs a new ScaffoldError instance.
40
+ *
41
+ * @param code - Machine-readable error code.
42
+ * @param message - Human-readable explanation of the error.
43
+ */
44
+ constructor(code: ScaffoldErrorCode, message: string) {
45
+ super(message);
46
+ this.code = code;
47
+ }
48
+ }
49
+
50
+ export default { ERROR_CODES, ScaffoldError };
package/src/git.ts ADDED
@@ -0,0 +1,42 @@
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
+ /**
19
+ * Safely executes an allowlisted git subcommand using execFileSync with an argument array and shell disabled.
20
+ *
21
+ * @param args - Subcommand and arguments array to pass to git.
22
+ * @param options - Child process execution options.
23
+ * @returns Standard output from git as a string.
24
+ * @throws {ScaffoldError} When args is empty or the subcommand is not in GIT_ALLOWED_SUBCOMMANDS.
25
+ */
26
+ export function runGit(args: string[], options: cp.ExecFileSyncOptions = {}): string {
27
+ if (!Array.isArray(args) || args.length === 0) {
28
+ throw new ScaffoldError('E_GIT_ARGS_INVALID', ERROR_CODES.E_GIT_ARGS_INVALID);
29
+ }
30
+ const subcommand = args[0];
31
+ if (!subcommand || !GIT_ALLOWED_SUBCOMMANDS.has(subcommand)) {
32
+ throw new ScaffoldError('E_GIT_BLOCKED', `${ERROR_CODES.E_GIT_BLOCKED}: ${String(subcommand)}`);
33
+ }
34
+ return cp.execFileSync('git', args, {
35
+ encoding: 'utf-8',
36
+ stdio: 'pipe',
37
+ shell: false,
38
+ ...options
39
+ }) as string;
40
+ }
41
+
42
+ export default { GIT_ALLOWED_SUBCOMMANDS, runGit };
package/src/guards.ts ADDED
@@ -0,0 +1,116 @@
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
+ /**
9
+ * Options for configuring protected target verification.
10
+ */
11
+ export interface ProtectedTargetOptions {
12
+ templateDir?: string;
13
+ force?: boolean;
14
+ dryRun?: boolean;
15
+ allowProtected?: boolean;
16
+ }
17
+
18
+ /**
19
+ * Filesystem scope guard (Supply Chain: filesystem access).
20
+ * Constrains all reads/writes to targetDir / templateDir, throwing if candidate escapes root.
21
+ *
22
+ * @param root - Absolute base directory.
23
+ * @param candidate - Candidate file or directory path to check.
24
+ * @param message - Optional custom error message.
25
+ * @throws {ScaffoldError} When candidate path escapes root directory.
26
+ */
27
+ export function assertInside(root: string, candidate: string, message?: string): void {
28
+ const resolvedRoot = path.resolve(root);
29
+ const resolvedCandidate = path.resolve(root, candidate);
30
+ const relative = path.relative(resolvedRoot, resolvedCandidate);
31
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
32
+ throw new ScaffoldError('E_PATH_ESCAPE', message || `${ERROR_CODES.E_PATH_ESCAPE}: ${candidate}`);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Normalizes a path for protected-target comparison. On Windows the filesystem
38
+ * is case-insensitive, so `C:\Users\Name` and `c:\users\name` are the same
39
+ * directory and must compare equal.
40
+ *
41
+ * @param p - File or directory path to normalize.
42
+ * @returns Normalized path string.
43
+ */
44
+ export function normalizeForComparison(p: string): string {
45
+ const resolved = path.resolve(p);
46
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
47
+ }
48
+
49
+ function readHomeDir(): string {
50
+ try {
51
+ return os.homedir();
52
+ } catch {
53
+ return '';
54
+ }
55
+ }
56
+
57
+ function isFilesystemRoot(resolvedTarget: string): boolean {
58
+ return resolvedTarget === path.parse(resolvedTarget).root;
59
+ }
60
+
61
+ function isHomeOrParent(normalizedTarget: string, home: string): boolean {
62
+ if (!home) return false;
63
+ if (normalizedTarget === normalizeForComparison(home)) return true;
64
+ const parent = path.dirname(path.resolve(home));
65
+ return normalizedTarget === normalizeForComparison(parent);
66
+ }
67
+
68
+ function isTemplateAncestor(resolvedTarget: string, templateDir: string): boolean {
69
+ const resolvedTemplate = path.resolve(templateDir);
70
+ const relative = path.relative(resolvedTarget, resolvedTemplate);
71
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
72
+ }
73
+
74
+ function resolvesToProtectedLocation(resolvedTarget: string, home: string): boolean {
75
+ // Symlink alias: a symlink to / or ~ must not bypass the lexical checks.
76
+ let real = '';
77
+ try {
78
+ if (!fs.existsSync(resolvedTarget)) return false;
79
+ real = fs.realpathSync(resolvedTarget);
80
+ } catch {
81
+ return false;
82
+ }
83
+ if (real === path.parse(real).root) return true;
84
+ return Boolean(home) && normalizeForComparison(real) === normalizeForComparison(home);
85
+ }
86
+
87
+ /**
88
+ * Returns true when `targetDir` is a location the scaffolder must never write
89
+ * into: the filesystem root, the user's home directory, the home directory's
90
+ * parent (e.g. /home, C:\Users -- scaffolding there affects every user), or an
91
+ * ancestor of the template itself (scaffolding into D:\projects would merge
92
+ * the template into its own parent).
93
+ *
94
+ * Lexical comparison is not enough: a symlink pointing at home must also be
95
+ * caught, so an existing target is resolved with realpath before comparing.
96
+ *
97
+ * @param targetDir - The target directory path to evaluate.
98
+ * @param options - Optional configuration for protected target evaluation.
99
+ * @returns True if target is a protected location, false otherwise.
100
+ */
101
+ export function isProtectedTarget(targetDir: string, options: ProtectedTargetOptions = {}): boolean {
102
+ const resolvedTarget = path.resolve(targetDir);
103
+ const normalizedTarget = normalizeForComparison(resolvedTarget);
104
+ if (isFilesystemRoot(resolvedTarget)) return true;
105
+ const home = readHomeDir();
106
+ if (isHomeOrParent(normalizedTarget, home)) return true;
107
+ const { templateDir = getTemplateDir() } = options;
108
+ if (isTemplateAncestor(resolvedTarget, templateDir)) return true;
109
+ return resolvesToProtectedLocation(resolvedTarget, home);
110
+ }
111
+
112
+ export default {
113
+ assertInside,
114
+ normalizeForComparison,
115
+ isProtectedTarget
116
+ };
package/src/index.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @module @azcodr/azcodr
3
+ * Enterprise Architecture & Agentic Engineering Starter Template.
4
+ *
5
+ * Provides problem-first, topology-aligned project scaffolding with strict systemic atomicity,
6
+ * filesystem scope guards, git worktree initialization, and multi-agent harness parity.
7
+ */
8
+
9
+ /**
10
+ * Union type of all valid machine-readable error codes.
11
+ */
12
+ export type ScaffoldErrorCode =
13
+ | 'E_TARGET_IS_TEMPLATE'
14
+ | 'E_TARGET_NOT_EMPTY'
15
+ | 'E_TARGET_IS_PROTECTED'
16
+ | 'E_GIT_ARGS_INVALID'
17
+ | 'E_GIT_BLOCKED'
18
+ | 'E_PATH_ESCAPE';
19
+
20
+ /**
21
+ * Core scaffolding functions, guards, utilities, error classes, and constants.
22
+ */
23
+ export {
24
+ scaffold,
25
+ validateTarget,
26
+ copyTemplate,
27
+ ensureSymlink,
28
+ ensureSymlinkOrPointer,
29
+ isSameCaseInsensitiveFile,
30
+ makeScriptsExecutable,
31
+ initGit,
32
+ isInsideGitWorkTree,
33
+ runGit,
34
+ assertInside,
35
+ isProtectedTarget,
36
+ getTemplateDir,
37
+ TEMPLATE_ITEMS,
38
+ ScaffoldError,
39
+ ERROR_CODES
40
+ } from './scaffold.js';
41
+
42
+ /**
43
+ * Type definitions and option interfaces for scaffolding operations.
44
+ */
45
+ export type {
46
+ ScaffoldOptions,
47
+ ScaffoldResult,
48
+ ValidateTargetOptions,
49
+ CopyTemplateOptions,
50
+ EnsureSymlinkOptions,
51
+ InitGitOptions,
52
+ ScaffoldErrorShape
53
+ } from './scaffold.js';
54
+
55
+ import scaffoldModule from './scaffold.js';
56
+
57
+ /**
58
+ * Default export providing the unified azcodr scaffolding module.
59
+ */
60
+ export default scaffoldModule;
package/src/links.ts ADDED
@@ -0,0 +1,153 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Configuration options for symlink and pointer creation.
6
+ */
7
+ export interface EnsureSymlinkOptions {
8
+ targetDir: string;
9
+ linkName: string;
10
+ targetFileName: string;
11
+ dryRun?: boolean;
12
+ }
13
+
14
+ interface BothEntriesExistOptions {
15
+ targetDir: string;
16
+ linkName: string;
17
+ targetFileName: string;
18
+ entries: readonly string[];
19
+ }
20
+
21
+ function namesDifferOnlyByCase(linkName: string, targetFileName: string): boolean {
22
+ if (linkName.toLowerCase() !== targetFileName.toLowerCase()) return false;
23
+ return linkName !== targetFileName;
24
+ }
25
+
26
+ function readDirEntries(targetDir: string): string[] | null {
27
+ try {
28
+ return fs.readdirSync(targetDir);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function bothEntriesExist(options: BothEntriesExistOptions): boolean {
35
+ const { targetDir, linkName, targetFileName, entries } = options;
36
+ if (!entries.includes(targetFileName) || !entries.includes(linkName)) return false;
37
+ try {
38
+ fs.lstatSync(path.join(targetDir, targetFileName));
39
+ fs.lstatSync(path.join(targetDir, linkName));
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function accessiblePair(targetPath: string, linkPath: string): boolean {
47
+ if (!fs.existsSync(targetPath) || !fs.existsSync(linkPath)) return false;
48
+ try {
49
+ fs.lstatSync(targetPath);
50
+ fs.lstatSync(linkPath);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function removeIfExists(linkPath: string): void {
58
+ try {
59
+ fs.lstatSync(linkPath);
60
+ fs.rmSync(linkPath, { force: true });
61
+ } catch {
62
+ // Path does not exist, proceed
63
+ }
64
+ }
65
+
66
+ function copyFallback(targetDir: string, linkName: string, targetFileName: string): void {
67
+ const sourceFile = path.resolve(targetDir, targetFileName);
68
+ if (fs.existsSync(sourceFile)) {
69
+ fs.copyFileSync(sourceFile, path.join(targetDir, linkName));
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Detects whether linkName and targetFileName refer to the same entry on a case-insensitive filesystem.
75
+ * Uses exact directory listing (not existsSync, which lies on case-insensitive systems).
76
+ *
77
+ * @param targetDir - Directory containing the file entries.
78
+ * @param linkName - Name of the proposed symlink or alias entry.
79
+ * @param targetFileName - Target file name to match against.
80
+ * @returns True if both names resolve to the same disk file on a case-insensitive filesystem.
81
+ */
82
+ export function isSameCaseInsensitiveFile(
83
+ targetDir: string,
84
+ linkName: string,
85
+ targetFileName: string
86
+ ): boolean {
87
+ if (linkName === targetFileName) return true;
88
+ if (!namesDifferOnlyByCase(linkName, targetFileName)) return false;
89
+ const entries = readDirEntries(targetDir);
90
+ if (entries === null) return false;
91
+ if (bothEntriesExist({ targetDir, linkName, targetFileName, entries })) {
92
+ return !fs.lstatSync(path.join(targetDir, linkName)).isSymbolicLink();
93
+ }
94
+ const targetPath = path.join(targetDir, targetFileName);
95
+ const linkPath = path.join(targetDir, linkName);
96
+ return accessiblePair(targetPath, linkPath);
97
+ }
98
+
99
+ /**
100
+ * Safely creates or updates a symbolic link, falling back to a file copy if symlinks are unsupported.
101
+ *
102
+ * @param options - Options specifying target directory, link name, target file name, and dry-run mode.
103
+ * @returns True if the symlink or copy fallback was successfully established.
104
+ */
105
+ export function ensureSymlink(options: EnsureSymlinkOptions): boolean {
106
+ const { targetDir, linkName, targetFileName, dryRun = false } = options;
107
+ if (dryRun) return true;
108
+ if (isSameCaseInsensitiveFile(targetDir, linkName, targetFileName)) return true;
109
+ const linkPath = path.join(targetDir, linkName);
110
+ removeIfExists(linkPath);
111
+ try {
112
+ fs.symlinkSync(targetFileName, linkPath, 'file');
113
+ return true;
114
+ } catch {
115
+ copyFallback(targetDir, linkName, targetFileName);
116
+ return true;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Creates a symlink, falling back to a text pointer (not a full copy).
122
+ * Used for .github/copilot-instructions.md where a full AGENTS.md copy would
123
+ * break relative markdown links (they resolve from .github/, not root).
124
+ *
125
+ * @param options - Options specifying target directory, link name, target file name, and dry-run mode.
126
+ * @returns True if the symlink or pointer fallback was successfully established.
127
+ */
128
+ export function ensureSymlinkOrPointer(options: EnsureSymlinkOptions): boolean {
129
+ const { targetDir, linkName, targetFileName, dryRun = false } = options;
130
+ if (dryRun) return true;
131
+ const linkPath = path.join(targetDir, linkName);
132
+ try {
133
+ try {
134
+ fs.lstatSync(linkPath);
135
+ const existing = fs.existsSync(linkPath) ? fs.readFileSync(linkPath, 'utf-8') : '';
136
+ if (existing.trim() === targetFileName) return true;
137
+ fs.rmSync(linkPath, { force: true });
138
+ } catch {
139
+ // Path does not exist, proceed
140
+ }
141
+ fs.symlinkSync(targetFileName, linkPath, 'file');
142
+ return true;
143
+ } catch {
144
+ fs.writeFileSync(linkPath, `${targetFileName}\n`, 'utf-8');
145
+ return true;
146
+ }
147
+ }
148
+
149
+ export default {
150
+ isSameCaseInsensitiveFile,
151
+ ensureSymlink,
152
+ ensureSymlinkOrPointer
153
+ };