@bastani/atomic 0.5.0-1

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 (68) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +956 -0
  3. package/assets/settings.schema.json +52 -0
  4. package/package.json +68 -0
  5. package/src/cli.ts +197 -0
  6. package/src/commands/cli/chat/client.ts +18 -0
  7. package/src/commands/cli/chat/index.ts +247 -0
  8. package/src/commands/cli/chat.ts +8 -0
  9. package/src/commands/cli/config.ts +55 -0
  10. package/src/commands/cli/init/index.ts +452 -0
  11. package/src/commands/cli/init/onboarding.ts +45 -0
  12. package/src/commands/cli/init/scm.ts +190 -0
  13. package/src/commands/cli/init.ts +8 -0
  14. package/src/commands/cli/update.ts +46 -0
  15. package/src/commands/cli/workflow.ts +164 -0
  16. package/src/lib/merge.ts +65 -0
  17. package/src/lib/path-root-guard.ts +38 -0
  18. package/src/lib/spawn.ts +467 -0
  19. package/src/scripts/bump-version.ts +94 -0
  20. package/src/scripts/constants-base.ts +14 -0
  21. package/src/scripts/constants.ts +34 -0
  22. package/src/sdk/components/color-utils.ts +20 -0
  23. package/src/sdk/components/connectors.test.ts +661 -0
  24. package/src/sdk/components/connectors.ts +156 -0
  25. package/src/sdk/components/edge.tsx +11 -0
  26. package/src/sdk/components/error-boundary.tsx +38 -0
  27. package/src/sdk/components/graph-theme.ts +36 -0
  28. package/src/sdk/components/header.tsx +60 -0
  29. package/src/sdk/components/layout.test.ts +924 -0
  30. package/src/sdk/components/layout.ts +186 -0
  31. package/src/sdk/components/node-card.tsx +68 -0
  32. package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
  33. package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
  34. package/src/sdk/components/orchestrator-panel-store.ts +118 -0
  35. package/src/sdk/components/orchestrator-panel-types.ts +21 -0
  36. package/src/sdk/components/orchestrator-panel.tsx +143 -0
  37. package/src/sdk/components/session-graph-panel.tsx +364 -0
  38. package/src/sdk/components/status-helpers.ts +32 -0
  39. package/src/sdk/components/statusline.tsx +63 -0
  40. package/src/sdk/define-workflow.ts +98 -0
  41. package/src/sdk/errors.ts +39 -0
  42. package/src/sdk/index.ts +38 -0
  43. package/src/sdk/providers/claude.ts +316 -0
  44. package/src/sdk/providers/copilot.ts +43 -0
  45. package/src/sdk/providers/opencode.ts +43 -0
  46. package/src/sdk/runtime/discovery.ts +172 -0
  47. package/src/sdk/runtime/executor.test.ts +415 -0
  48. package/src/sdk/runtime/executor.ts +695 -0
  49. package/src/sdk/runtime/loader.ts +372 -0
  50. package/src/sdk/runtime/panel.tsx +9 -0
  51. package/src/sdk/runtime/theme.ts +76 -0
  52. package/src/sdk/runtime/tmux.ts +542 -0
  53. package/src/sdk/types.ts +114 -0
  54. package/src/sdk/workflows.ts +85 -0
  55. package/src/services/config/atomic-config.ts +124 -0
  56. package/src/services/config/atomic-global-config.ts +361 -0
  57. package/src/services/config/config-path.ts +19 -0
  58. package/src/services/config/definitions.ts +176 -0
  59. package/src/services/config/index.ts +7 -0
  60. package/src/services/config/settings-schema.ts +2 -0
  61. package/src/services/config/settings.ts +149 -0
  62. package/src/services/system/copy.ts +381 -0
  63. package/src/services/system/detect.ts +161 -0
  64. package/src/services/system/download.ts +325 -0
  65. package/src/services/system/file-lock.ts +289 -0
  66. package/src/services/system/skills.ts +67 -0
  67. package/src/theme/colors.ts +25 -0
  68. package/src/version.ts +7 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Agent configuration definitions for atomic CLI
3
+ */
4
+
5
+ export interface AgentConfig {
6
+ /** Display name for the agent */
7
+ name: string;
8
+ /** Command to execute the agent */
9
+ cmd: string;
10
+ /** Flags used when spawning the agent in interactive chat mode */
11
+ chat_flags: string[];
12
+ /** Config folder relative to repo root */
13
+ folder: string;
14
+ /** URL for installation instructions */
15
+ install_url: string;
16
+ /** Paths to exclude when copying (relative to folder) */
17
+ exclude: string[];
18
+ /** Project files managed by `atomic init` for provider onboarding */
19
+ onboarding_files: Array<{
20
+ source: string;
21
+ destination: string;
22
+ merge: boolean;
23
+ }>;
24
+ }
25
+
26
+ const AGENT_KEYS = ["claude", "opencode", "copilot"] as const;
27
+ export type AgentKey = (typeof AGENT_KEYS)[number];
28
+
29
+ export const AGENT_CONFIG: Record<AgentKey, AgentConfig> = {
30
+ claude: {
31
+ name: "Claude Code",
32
+ cmd: "claude",
33
+ chat_flags: ["--allow-dangerously-skip-permissions", "--dangerously-skip-permissions"],
34
+ folder: ".claude",
35
+ install_url: "https://code.claude.com/docs/en/setup",
36
+ exclude: [".DS_Store", "settings.json"],
37
+ onboarding_files: [
38
+ {
39
+ source: ".mcp.json",
40
+ destination: ".mcp.json",
41
+ merge: true,
42
+ },
43
+ {
44
+ source: ".claude/settings.json",
45
+ destination: ".claude/settings.json",
46
+ merge: true,
47
+ },
48
+ ],
49
+ },
50
+ opencode: {
51
+ name: "OpenCode",
52
+ cmd: "opencode",
53
+ chat_flags: [],
54
+ folder: ".opencode",
55
+ install_url: "https://opencode.ai",
56
+ exclude: [
57
+ "node_modules",
58
+ ".gitignore",
59
+ "bun.lock",
60
+ "package.json",
61
+ ".DS_Store",
62
+ "opencode.json",
63
+ ],
64
+ onboarding_files: [
65
+ {
66
+ source: ".opencode/opencode.json",
67
+ destination: ".opencode/opencode.json",
68
+ merge: true,
69
+ },
70
+ ],
71
+ },
72
+ copilot: {
73
+ name: "GitHub Copilot CLI",
74
+ cmd: "copilot",
75
+ chat_flags: ["--add-dir", ".", "--yolo", "--experimental"],
76
+ folder: ".github",
77
+ install_url:
78
+ "https://github.com/github/copilot-cli?tab=readme-ov-file#installation",
79
+ exclude: ["workflows", "dependabot.yml", ".DS_Store"],
80
+ onboarding_files: [
81
+ {
82
+ source: ".vscode/mcp.json",
83
+ destination: ".vscode/mcp.json",
84
+ merge: true,
85
+ },
86
+ ],
87
+ },
88
+ };
89
+
90
+ export function isValidAgent(key: string): key is AgentKey {
91
+ return key in AGENT_CONFIG;
92
+ }
93
+
94
+ export function getAgentConfig(key: AgentKey): AgentConfig {
95
+ return AGENT_CONFIG[key];
96
+ }
97
+
98
+ export function getAgentKeys(): AgentKey[] {
99
+ return [...AGENT_KEYS];
100
+ }
101
+
102
+ /**
103
+ * Source Control Management (SCM) configuration definitions
104
+ */
105
+
106
+ /** Supported source control types */
107
+ export type SourceControlType = "github" | "sapling";
108
+ // Future: | 'azure-devops'
109
+
110
+ /** SCM keys for iteration */
111
+ const SCM_KEYS = ["github", "sapling"] as const;
112
+
113
+ export interface ScmConfig {
114
+ /** Internal identifier */
115
+ name: string;
116
+ /** Display name for prompts */
117
+ displayName: string;
118
+ /** Primary CLI tool (git or sl) */
119
+ cliTool: string;
120
+ /** Code review tool (gh, jf submit, arc diff, etc.) */
121
+ reviewTool: string;
122
+ /** Code review system (github, phabricator) */
123
+ reviewSystem: string;
124
+ /** Directory marker for potential future auto-detection */
125
+ detectDir: string;
126
+ /** Code review command file name */
127
+ reviewCommandFile: string;
128
+ /** Required configuration files */
129
+ requiredConfigFiles?: string[];
130
+ }
131
+
132
+ export const SCM_CONFIG: Record<SourceControlType, ScmConfig> = {
133
+ github: {
134
+ name: "github",
135
+ displayName: "GitHub / Git",
136
+ cliTool: "git",
137
+ reviewTool: "gh",
138
+ reviewSystem: "github",
139
+ detectDir: ".git",
140
+ reviewCommandFile: "create-gh-pr.md",
141
+ },
142
+ sapling: {
143
+ name: "sapling",
144
+ displayName: "Sapling + Phabricator",
145
+ cliTool: "sl",
146
+ reviewTool: "jf submit",
147
+ reviewSystem: "phabricator",
148
+ detectDir: ".sl",
149
+ reviewCommandFile: "submit-diff.md",
150
+ requiredConfigFiles: [".arcconfig", "~/.arcrc"],
151
+ },
152
+ };
153
+
154
+ /** Commands that have SCM-specific variants */
155
+ export const SCM_SPECIFIC_COMMANDS = ["commit"];
156
+
157
+ /**
158
+ * Get all SCM keys for iteration
159
+ */
160
+ export function getScmKeys(): SourceControlType[] {
161
+ return [...SCM_KEYS];
162
+ }
163
+
164
+ /**
165
+ * Check if a string is a valid SCM type
166
+ */
167
+ export function isValidScm(key: string): key is SourceControlType {
168
+ return key in SCM_CONFIG;
169
+ }
170
+
171
+ /**
172
+ * Get the configuration for a specific SCM type
173
+ */
174
+ export function getScmConfig(key: SourceControlType): ScmConfig {
175
+ return SCM_CONFIG[key];
176
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Configuration Module Exports
3
+ *
4
+ * Centralized access to the CLI's agent and SCM configuration.
5
+ */
6
+
7
+ export * from "@/services/config/definitions.ts";
@@ -0,0 +1,2 @@
1
+ export const SETTINGS_SCHEMA_URL =
2
+ "https://raw.githubusercontent.com/flora131/atomic/main/assets/settings.schema.json";
@@ -0,0 +1,149 @@
1
+ /**
2
+ * User settings persistence
3
+ *
4
+ * Stores user settings (e.g., model selection) across sessions.
5
+ * Settings are resolved in priority order:
6
+ * 1. .atomic/settings.json (project-local, higher priority)
7
+ * 2. ~/.atomic/settings.json (global, lower priority)
8
+ *
9
+ * The --model CLI flag takes precedence over both (handled at call site).
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
13
+ import { join, dirname, resolve } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { SETTINGS_SCHEMA_URL } from "@/services/config/settings-schema.ts";
16
+ import { ensureDirSync } from "@/services/system/copy.ts";
17
+ import type { AgentKey } from "@/services/config/definitions.ts";
18
+
19
+ export interface TrustedPathEntry {
20
+ workspacePath: string;
21
+ provider: AgentKey;
22
+ }
23
+
24
+ interface AtomicSettings {
25
+ $schema?: string;
26
+ scm?: "github" | "sapling";
27
+ version?: number;
28
+ lastUpdated?: string;
29
+ prerelease?: boolean;
30
+ trustedPaths?: TrustedPathEntry[];
31
+ }
32
+
33
+ /** Global settings path: ~/.atomic/settings.json */
34
+ function globalSettingsPath(): string {
35
+ const home = process.env.ATOMIC_SETTINGS_HOME ?? homedir();
36
+ return join(home, ".atomic", "settings.json");
37
+ }
38
+
39
+ /** Local settings path: {cwd}/.atomic/settings.json (CWD-scoped by design) */
40
+ function localSettingsPath(): string {
41
+ const cwd = process.env.ATOMIC_SETTINGS_CWD ?? process.cwd();
42
+ return join(cwd, ".atomic", "settings.json");
43
+ }
44
+
45
+ function loadSettingsFileSync(path: string): AtomicSettings {
46
+ try {
47
+ if (existsSync(path)) {
48
+ return JSON.parse(readFileSync(path, "utf-8")) as AtomicSettings;
49
+ }
50
+ } catch {
51
+ // Silently fail
52
+ }
53
+ return {};
54
+ }
55
+
56
+ async function loadSettingsFile(path: string): Promise<AtomicSettings> {
57
+ try {
58
+ return await Bun.file(path).json() as AtomicSettings;
59
+ } catch {
60
+ // Silently fail (file doesn't exist or invalid JSON)
61
+ }
62
+ return {};
63
+ }
64
+
65
+ function writeGlobalSettingsSync(settings: AtomicSettings): void {
66
+ const path = globalSettingsPath();
67
+ const dir = dirname(path);
68
+ if (!existsSync(dir)) ensureDirSync(dir);
69
+ writeFileSync(path, JSON.stringify(settings, null, 2), "utf-8");
70
+ }
71
+
72
+ function normalizeTrustedPathEntry(entry: TrustedPathEntry): TrustedPathEntry {
73
+ return {
74
+ workspacePath: resolve(entry.workspacePath),
75
+ provider: entry.provider,
76
+ };
77
+ }
78
+
79
+ function normalizeTrustedPaths(entries: TrustedPathEntry[] | undefined): TrustedPathEntry[] {
80
+ const deduped = new Map<string, TrustedPathEntry>();
81
+
82
+ for (const entry of entries ?? []) {
83
+ if (
84
+ typeof entry.workspacePath !== "string" ||
85
+ typeof entry.provider !== "string"
86
+ ) {
87
+ continue;
88
+ }
89
+
90
+ const normalizedEntry = normalizeTrustedPathEntry(entry);
91
+ deduped.set(
92
+ `${normalizedEntry.provider}:${normalizedEntry.workspacePath}`,
93
+ normalizedEntry,
94
+ );
95
+ }
96
+
97
+ return Array.from(deduped.values());
98
+ }
99
+
100
+ /**
101
+ * Get the prerelease channel preference.
102
+ * Only checks global settings (~/.atomic/settings.json) since this is an install-level setting.
103
+ */
104
+ export function getPrereleasePreference(): boolean {
105
+ return loadSettingsFileSync(globalSettingsPath()).prerelease === true;
106
+ }
107
+
108
+ export async function isTrustedWorkspacePath(
109
+ workspacePath: string,
110
+ provider: AgentKey,
111
+ ): Promise<boolean> {
112
+ const settings = await loadSettingsFile(globalSettingsPath());
113
+ const normalizedWorkspacePath = resolve(workspacePath);
114
+
115
+ return normalizeTrustedPaths(settings.trustedPaths).some((entry) =>
116
+ entry.provider === provider && entry.workspacePath === normalizedWorkspacePath
117
+ );
118
+ }
119
+
120
+ export function upsertTrustedWorkspacePath(
121
+ workspacePath: string,
122
+ provider: AgentKey,
123
+ ): void {
124
+ try {
125
+ const settings = loadSettingsFileSync(globalSettingsPath());
126
+ settings.$schema = SETTINGS_SCHEMA_URL;
127
+ settings.trustedPaths = normalizeTrustedPaths([
128
+ ...(settings.trustedPaths ?? []),
129
+ { workspacePath, provider },
130
+ ]);
131
+ writeGlobalSettingsSync(settings);
132
+ } catch {
133
+ // Silently fail
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Set telemetry enabled/disabled in global settings.
139
+ */
140
+ export function setTelemetryEnabled(enabled: boolean): void {
141
+ try {
142
+ const settings = loadSettingsFileSync(globalSettingsPath());
143
+ settings.$schema = SETTINGS_SCHEMA_URL;
144
+ (settings as Record<string, unknown>).telemetryEnabled = enabled;
145
+ writeGlobalSettingsSync(settings);
146
+ } catch {
147
+ // Silently fail
148
+ }
149
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Utilities for copying directories and files with exclusions
3
+ */
4
+
5
+ import { readdir, mkdir, stat, readFile } from "fs/promises";
6
+ import { mkdirSync } from "fs";
7
+ import { join, extname, relative, resolve } from "path";
8
+
9
+ /**
10
+ * Safely create a directory (and parents) without throwing on EEXIST.
11
+ *
12
+ * `mkdir` with `{ recursive: true }` is supposed to be idempotent, but
13
+ * cloud-sync tools like OneDrive can create the directory between the
14
+ * internal existence check and the actual syscall, causing a spurious
15
+ * EEXIST error on Windows. This wrapper absorbs that race.
16
+ */
17
+ export async function ensureDir(path: string): Promise<void> {
18
+ try {
19
+ await mkdir(path, { recursive: true });
20
+ } catch (error: unknown) {
21
+ if (
22
+ error instanceof Error &&
23
+ "code" in error &&
24
+ (error as NodeJS.ErrnoException).code === "EEXIST"
25
+ ) {
26
+ return;
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Synchronous version of {@link ensureDir}.
34
+ */
35
+ export function ensureDirSync(path: string): void {
36
+ try {
37
+ mkdirSync(path, { recursive: true });
38
+ } catch (error: unknown) {
39
+ if (
40
+ error instanceof Error &&
41
+ "code" in error &&
42
+ (error as NodeJS.ErrnoException).code === "EEXIST"
43
+ ) {
44
+ return;
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+ import { getOppositeScriptExtension } from "@/services/system/detect.ts";
50
+ import {
51
+ assertPathWithinRoot,
52
+ assertRealPathWithinRoot,
53
+ isPathWithinRoot,
54
+ } from "@/lib/path-root-guard.ts";
55
+
56
+ /**
57
+ * Normalize a path for cross-platform comparison.
58
+ * Converts Windows backslashes to forward slashes so that exclusion
59
+ * patterns work consistently on both Windows and Unix systems.
60
+ *
61
+ * @param p - The path to normalize
62
+ * @returns The path with all backslashes converted to forward slashes
63
+ */
64
+ export function normalizePath(p: string): string {
65
+ return p.replace(/\\/g, "/");
66
+ }
67
+
68
+ /**
69
+ * Check if a target path is safe (doesn't escape the base directory)
70
+ * Protects against path traversal attacks
71
+ */
72
+ export function isPathSafe(basePath: string, targetPath: string): boolean {
73
+ const resolvedTarget = resolve(basePath, targetPath);
74
+ return isPathWithinRoot(basePath, resolvedTarget);
75
+ }
76
+
77
+ interface CopyOptions {
78
+ /** Paths to exclude (relative to source root or base names) */
79
+ exclude?: string[];
80
+ /** Whether to skip scripts for the opposite platform */
81
+ skipOppositeScripts?: boolean;
82
+ }
83
+
84
+ /**
85
+ * Copy a single file using Bun's file API
86
+ * @throws Error if the copy operation fails
87
+ */
88
+ export async function copyFile(src: string, dest: string): Promise<void> {
89
+ if (resolve(src) === resolve(dest)) {
90
+ return;
91
+ }
92
+
93
+ try {
94
+ const srcFile = Bun.file(src);
95
+ await Bun.write(dest, srcFile);
96
+ } catch (error) {
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ throw new Error(`Failed to copy ${src} to ${dest}: ${message}`);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Copy a symlink by dereferencing it (copying the target content as a regular file)
104
+ * This ensures symlinks work on Windows without requiring special permissions
105
+ * @throws Error if the copy operation fails
106
+ */
107
+ async function copySymlinkAsFile(
108
+ src: string,
109
+ dest: string,
110
+ sourceRoot: string,
111
+ ): Promise<void> {
112
+ try {
113
+ // Resolve the symlink and ensure it cannot escape the source root
114
+ const resolvedPath = await assertRealPathWithinRoot(
115
+ sourceRoot,
116
+ src,
117
+ "Symlink source",
118
+ );
119
+ const stats = await stat(resolvedPath);
120
+
121
+ if (stats.isFile()) {
122
+ // Copy the target file content
123
+ await copyFile(resolvedPath, dest);
124
+ }
125
+ // If symlink points to a directory, we skip it (rare case, could be handled if needed)
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ throw new Error(`Failed to copy symlink ${src} to ${dest}: ${message}`);
129
+ }
130
+ }
131
+
132
+ async function copyFileWithOverwriteOption(
133
+ src: string,
134
+ dest: string,
135
+ overwriteExisting: boolean,
136
+ ): Promise<void> {
137
+ if (!overwriteExisting && (await pathExists(dest))) {
138
+ return;
139
+ }
140
+
141
+ await copyFile(src, dest);
142
+ }
143
+
144
+ async function copySymlinkAsFileWithOverwriteOption(
145
+ src: string,
146
+ dest: string,
147
+ sourceRoot: string,
148
+ overwriteExisting: boolean,
149
+ ): Promise<void> {
150
+ if (!overwriteExisting && (await pathExists(dest))) {
151
+ return;
152
+ }
153
+
154
+ await copySymlinkAsFile(src, dest, sourceRoot);
155
+ }
156
+
157
+ /**
158
+ * Check if a path should be excluded based on exclusion rules.
159
+ * Uses normalized paths (forward slashes) to ensure consistent matching
160
+ * on both Windows and Unix systems.
161
+ */
162
+ export function shouldExclude(
163
+ relativePath: string,
164
+ name: string,
165
+ exclude: string[]
166
+ ): boolean {
167
+ // Check if the name matches any exclusion
168
+ if (exclude.includes(name)) {
169
+ return true;
170
+ }
171
+
172
+ // Normalize the relative path for cross-platform comparison
173
+ // This ensures Windows backslash paths match forward-slash exclusion patterns
174
+ const normalizedPath = normalizePath(relativePath);
175
+
176
+ // Check if the relative path starts with any exclusion
177
+ for (const ex of exclude) {
178
+ const normalizedExclusion = normalizePath(ex);
179
+ if (
180
+ normalizedPath === normalizedExclusion ||
181
+ normalizedPath.startsWith(`${normalizedExclusion}/`)
182
+ ) {
183
+ return true;
184
+ }
185
+ }
186
+
187
+ return false;
188
+ }
189
+
190
+ /**
191
+ * Recursively copy a directory with exclusions
192
+ *
193
+ * @param src Source directory path
194
+ * @param dest Destination directory path
195
+ * @param options Copy options including exclusions
196
+ * @param rootSrc Root source path for calculating relative paths (used internally)
197
+ * @throws Error if the copy operation fails or path traversal is detected
198
+ */
199
+ async function copyDirInternal(
200
+ src: string,
201
+ dest: string,
202
+ options: CopyOptions = {},
203
+ rootSrc?: string,
204
+ rootDest?: string,
205
+ overwriteExisting: boolean = true,
206
+ ): Promise<void> {
207
+ try {
208
+ const { exclude = [], skipOppositeScripts = true } = options;
209
+ const root = rootSrc ?? src;
210
+ const destinationRoot = rootDest ?? dest;
211
+
212
+ assertPathWithinRoot(root, src, "Source path");
213
+ assertPathWithinRoot(destinationRoot, dest, "Destination path");
214
+
215
+ await assertRealPathWithinRoot(root, src, "Source path");
216
+
217
+ // Create destination directory
218
+ await ensureDir(dest);
219
+
220
+ // Read source directory entries
221
+ const entries = await readdir(src, { withFileTypes: true });
222
+
223
+ // Get the opposite script extension for filtering
224
+ const oppositeExt = getOppositeScriptExtension();
225
+
226
+ // Process entries in parallel for better performance
227
+ const copyPromises: Promise<void>[] = [];
228
+
229
+ for (const entry of entries) {
230
+ const srcPath = join(src, entry.name);
231
+ const destPath = join(dest, entry.name);
232
+
233
+ assertPathWithinRoot(root, srcPath, "Source entry path");
234
+ assertPathWithinRoot(destinationRoot, destPath, "Destination entry path");
235
+
236
+ if (!isPathSafe(src, entry.name) || !isPathSafe(dest, entry.name)) {
237
+ throw new Error(`Path traversal detected: ${entry.name}`);
238
+ }
239
+
240
+ // Calculate relative path from root using path.relative for cross-platform support
241
+ const relativePath = relative(root, srcPath);
242
+
243
+ if (relativePath.startsWith("..")) {
244
+ throw new Error(`Path traversal detected: ${srcPath}`);
245
+ }
246
+
247
+ // Check if this path should be excluded
248
+ if (shouldExclude(relativePath, entry.name, exclude)) {
249
+ continue;
250
+ }
251
+
252
+ // Skip scripts for the opposite platform
253
+ if (skipOppositeScripts && extname(entry.name) === oppositeExt) {
254
+ continue;
255
+ }
256
+
257
+ if (entry.isDirectory()) {
258
+ // Directories are processed recursively (which will parallelize their contents)
259
+ copyPromises.push(
260
+ copyDirInternal(
261
+ srcPath,
262
+ destPath,
263
+ options,
264
+ root,
265
+ destinationRoot,
266
+ overwriteExisting,
267
+ ),
268
+ );
269
+ } else if (entry.isFile()) {
270
+ copyPromises.push(
271
+ copyFileWithOverwriteOption(srcPath, destPath, overwriteExisting),
272
+ );
273
+ } else if (entry.isSymbolicLink()) {
274
+ // Dereference symlinks: resolve target and copy as regular file
275
+ copyPromises.push(
276
+ copySymlinkAsFileWithOverwriteOption(
277
+ srcPath,
278
+ destPath,
279
+ root,
280
+ overwriteExisting,
281
+ ),
282
+ );
283
+ }
284
+ // Skip other special files (block devices, etc.)
285
+ }
286
+
287
+ // Wait for all copy operations to complete
288
+ await Promise.all(copyPromises);
289
+ } catch (error) {
290
+ // Re-throw errors with more context if they don't already have it
291
+ if (error instanceof Error && error.message.includes("Failed to copy")) {
292
+ throw error;
293
+ }
294
+ const message = error instanceof Error ? error.message : String(error);
295
+ throw new Error(`Failed to copy directory ${src} to ${dest}: ${message}`);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Recursively copy a directory, overwriting existing destination files.
301
+ */
302
+ export async function copyDir(
303
+ src: string,
304
+ dest: string,
305
+ options: CopyOptions = {},
306
+ rootSrc?: string,
307
+ rootDest?: string,
308
+ ): Promise<void> {
309
+ await copyDirInternal(src, dest, options, rootSrc, rootDest, true);
310
+ }
311
+
312
+ /**
313
+ * Recursively copy a directory without overwriting existing destination files.
314
+ */
315
+ export async function copyDirNonDestructive(
316
+ src: string,
317
+ dest: string,
318
+ options: CopyOptions = {},
319
+ rootSrc?: string,
320
+ rootDest?: string,
321
+ ): Promise<void> {
322
+ await copyDirInternal(src, dest, options, rootSrc, rootDest, false);
323
+ }
324
+
325
+ /**
326
+ * Check if a path exists
327
+ */
328
+ export async function pathExists(path: string): Promise<boolean> {
329
+ try {
330
+ await stat(path);
331
+ return true;
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Check if a path is a directory
339
+ */
340
+ export async function isDirectory(path: string): Promise<boolean> {
341
+ try {
342
+ const stats = await stat(path);
343
+ return stats.isDirectory();
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Check if a file is empty or contains only whitespace.
351
+ *
352
+ * A file is considered empty if:
353
+ * - It does not exist (returns true to allow overwrite)
354
+ * - It has 0 bytes
355
+ * - It contains only whitespace characters (for files under 1KB)
356
+ *
357
+ * @param path - The path to the file to check
358
+ * @returns true if the file is empty or whitespace-only, false otherwise
359
+ */
360
+ export async function isFileEmpty(path: string): Promise<boolean> {
361
+ try {
362
+ const stats = await stat(path);
363
+
364
+ // 0-byte files are empty
365
+ if (stats.size === 0) {
366
+ return true;
367
+ }
368
+
369
+ // For small files (under 1KB), check if content is whitespace-only
370
+ if (stats.size < 1024) {
371
+ const content = await readFile(path, "utf-8");
372
+ return content.trim().length === 0;
373
+ }
374
+
375
+ // Large files with content are not empty
376
+ return false;
377
+ } catch {
378
+ // If file doesn't exist or can't be read, treat as empty (allow overwrite)
379
+ return true;
380
+ }
381
+ }