@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.
- package/LICENSE +24 -0
- package/README.md +956 -0
- package/assets/settings.schema.json +52 -0
- package/package.json +68 -0
- package/src/cli.ts +197 -0
- package/src/commands/cli/chat/client.ts +18 -0
- package/src/commands/cli/chat/index.ts +247 -0
- package/src/commands/cli/chat.ts +8 -0
- package/src/commands/cli/config.ts +55 -0
- package/src/commands/cli/init/index.ts +452 -0
- package/src/commands/cli/init/onboarding.ts +45 -0
- package/src/commands/cli/init/scm.ts +190 -0
- package/src/commands/cli/init.ts +8 -0
- package/src/commands/cli/update.ts +46 -0
- package/src/commands/cli/workflow.ts +164 -0
- package/src/lib/merge.ts +65 -0
- package/src/lib/path-root-guard.ts +38 -0
- package/src/lib/spawn.ts +467 -0
- package/src/scripts/bump-version.ts +94 -0
- package/src/scripts/constants-base.ts +14 -0
- package/src/scripts/constants.ts +34 -0
- package/src/sdk/components/color-utils.ts +20 -0
- package/src/sdk/components/connectors.test.ts +661 -0
- package/src/sdk/components/connectors.ts +156 -0
- package/src/sdk/components/edge.tsx +11 -0
- package/src/sdk/components/error-boundary.tsx +38 -0
- package/src/sdk/components/graph-theme.ts +36 -0
- package/src/sdk/components/header.tsx +60 -0
- package/src/sdk/components/layout.test.ts +924 -0
- package/src/sdk/components/layout.ts +186 -0
- package/src/sdk/components/node-card.tsx +68 -0
- package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
- package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
- package/src/sdk/components/orchestrator-panel-store.ts +118 -0
- package/src/sdk/components/orchestrator-panel-types.ts +21 -0
- package/src/sdk/components/orchestrator-panel.tsx +143 -0
- package/src/sdk/components/session-graph-panel.tsx +364 -0
- package/src/sdk/components/status-helpers.ts +32 -0
- package/src/sdk/components/statusline.tsx +63 -0
- package/src/sdk/define-workflow.ts +98 -0
- package/src/sdk/errors.ts +39 -0
- package/src/sdk/index.ts +38 -0
- package/src/sdk/providers/claude.ts +316 -0
- package/src/sdk/providers/copilot.ts +43 -0
- package/src/sdk/providers/opencode.ts +43 -0
- package/src/sdk/runtime/discovery.ts +172 -0
- package/src/sdk/runtime/executor.test.ts +415 -0
- package/src/sdk/runtime/executor.ts +695 -0
- package/src/sdk/runtime/loader.ts +372 -0
- package/src/sdk/runtime/panel.tsx +9 -0
- package/src/sdk/runtime/theme.ts +76 -0
- package/src/sdk/runtime/tmux.ts +542 -0
- package/src/sdk/types.ts +114 -0
- package/src/sdk/workflows.ts +85 -0
- package/src/services/config/atomic-config.ts +124 -0
- package/src/services/config/atomic-global-config.ts +361 -0
- package/src/services/config/config-path.ts +19 -0
- package/src/services/config/definitions.ts +176 -0
- package/src/services/config/index.ts +7 -0
- package/src/services/config/settings-schema.ts +2 -0
- package/src/services/config/settings.ts +149 -0
- package/src/services/system/copy.ts +381 -0
- package/src/services/system/detect.ts +161 -0
- package/src/services/system/download.ts +325 -0
- package/src/services/system/file-lock.ts +289 -0
- package/src/services/system/skills.ts +67 -0
- package/src/theme/colors.ts +25 -0
- 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,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
|
+
}
|