@markjaquith/agency 0.5.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/LICENSE +21 -0
- package/README.md +109 -0
- package/cli.ts +569 -0
- package/index.ts +1 -0
- package/package.json +65 -0
- package/src/commands/base.test.ts +198 -0
- package/src/commands/base.ts +198 -0
- package/src/commands/clean.test.ts +299 -0
- package/src/commands/clean.ts +320 -0
- package/src/commands/emit.test.ts +412 -0
- package/src/commands/emit.ts +521 -0
- package/src/commands/emitted.test.ts +226 -0
- package/src/commands/emitted.ts +57 -0
- package/src/commands/init.test.ts +311 -0
- package/src/commands/init.ts +140 -0
- package/src/commands/merge.test.ts +365 -0
- package/src/commands/merge.ts +253 -0
- package/src/commands/pull.test.ts +385 -0
- package/src/commands/pull.ts +205 -0
- package/src/commands/push.test.ts +394 -0
- package/src/commands/push.ts +346 -0
- package/src/commands/save.test.ts +247 -0
- package/src/commands/save.ts +162 -0
- package/src/commands/source.test.ts +195 -0
- package/src/commands/source.ts +72 -0
- package/src/commands/status.test.ts +489 -0
- package/src/commands/status.ts +258 -0
- package/src/commands/switch.test.ts +194 -0
- package/src/commands/switch.ts +84 -0
- package/src/commands/task-branching.test.ts +334 -0
- package/src/commands/task-edit.test.ts +141 -0
- package/src/commands/task-main.test.ts +872 -0
- package/src/commands/task.ts +712 -0
- package/src/commands/tasks.test.ts +335 -0
- package/src/commands/tasks.ts +155 -0
- package/src/commands/template-delete.test.ts +178 -0
- package/src/commands/template-delete.ts +98 -0
- package/src/commands/template-list.test.ts +135 -0
- package/src/commands/template-list.ts +87 -0
- package/src/commands/template-view.test.ts +158 -0
- package/src/commands/template-view.ts +86 -0
- package/src/commands/template.test.ts +32 -0
- package/src/commands/template.ts +96 -0
- package/src/commands/use.test.ts +87 -0
- package/src/commands/use.ts +97 -0
- package/src/commands/work.test.ts +462 -0
- package/src/commands/work.ts +193 -0
- package/src/errors.ts +17 -0
- package/src/schemas.ts +33 -0
- package/src/services/AgencyMetadataService.ts +287 -0
- package/src/services/ClaudeService.test.ts +184 -0
- package/src/services/ClaudeService.ts +91 -0
- package/src/services/ConfigService.ts +115 -0
- package/src/services/FileSystemService.ts +222 -0
- package/src/services/GitService.ts +751 -0
- package/src/services/OpencodeService.ts +263 -0
- package/src/services/PromptService.ts +183 -0
- package/src/services/TemplateService.ts +75 -0
- package/src/test-utils.ts +362 -0
- package/src/types/native-exec.d.ts +8 -0
- package/src/types.ts +216 -0
- package/src/utils/colors.ts +178 -0
- package/src/utils/command.ts +17 -0
- package/src/utils/effect.ts +281 -0
- package/src/utils/exec.ts +48 -0
- package/src/utils/paths.ts +51 -0
- package/src/utils/pr-branch.test.ts +372 -0
- package/src/utils/pr-branch.ts +473 -0
- package/src/utils/process.ts +110 -0
- package/src/utils/spinner.ts +82 -0
- package/templates/AGENCY.md +20 -0
- package/templates/AGENTS.md +11 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/TASK.md +5 -0
- package/templates/opencode.json +4 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color highlighting utilities for CLI output
|
|
3
|
+
*
|
|
4
|
+
* This module provides centralized color management for highlighting
|
|
5
|
+
* meaningful values in CLI feedback. All highlight types use the same
|
|
6
|
+
* base color initially, but are separated into buckets to allow for
|
|
7
|
+
* future customization.
|
|
8
|
+
*
|
|
9
|
+
* Colors can be disabled via:
|
|
10
|
+
* - NO_COLOR environment variable (standard)
|
|
11
|
+
* - Setting colorsEnabled to false
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ANSI color codes
|
|
15
|
+
const RESET = "\x1b[0m"
|
|
16
|
+
const CYAN_BRIGHT = "\x1b[96m"
|
|
17
|
+
const GREEN = "\x1b[32m"
|
|
18
|
+
const YELLOW = "\x1b[33m"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Central color configuration
|
|
22
|
+
* All buckets use the same color initially (bright cyan)
|
|
23
|
+
* but can be easily changed independently in the future
|
|
24
|
+
*/
|
|
25
|
+
const COLORS = {
|
|
26
|
+
branch: CYAN_BRIGHT,
|
|
27
|
+
template: CYAN_BRIGHT,
|
|
28
|
+
file: CYAN_BRIGHT,
|
|
29
|
+
setting: CYAN_BRIGHT,
|
|
30
|
+
value: CYAN_BRIGHT,
|
|
31
|
+
commit: CYAN_BRIGHT,
|
|
32
|
+
pattern: CYAN_BRIGHT,
|
|
33
|
+
remote: YELLOW,
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Global flag to enable/disable colors
|
|
38
|
+
* Defaults to enabled unless NO_COLOR environment variable is set
|
|
39
|
+
*/
|
|
40
|
+
let colorsEnabled = !process.env.NO_COLOR
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Enable or disable color output
|
|
44
|
+
* @param enabled - Whether to enable colors
|
|
45
|
+
*/
|
|
46
|
+
export function setColorsEnabled(enabled: boolean): void {
|
|
47
|
+
colorsEnabled = enabled
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if colors are currently enabled
|
|
52
|
+
*/
|
|
53
|
+
function areColorsEnabled(): boolean {
|
|
54
|
+
return colorsEnabled
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Internal helper to apply color formatting
|
|
59
|
+
* Returns plain text if colors are disabled
|
|
60
|
+
*/
|
|
61
|
+
function colorize(text: string, color: string): string {
|
|
62
|
+
if (!colorsEnabled) {
|
|
63
|
+
return text
|
|
64
|
+
}
|
|
65
|
+
return `${color}${text}${RESET}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Highlight a branch name
|
|
70
|
+
* @example highlight.branch("main") -> "\x1b[96mmain\x1b[0m"
|
|
71
|
+
*/
|
|
72
|
+
function branch(name: string): string {
|
|
73
|
+
return colorize(name, COLORS.branch)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Highlight a template name
|
|
78
|
+
* @example highlight.template("my-template") -> "\x1b[96mmy-template\x1b[0m"
|
|
79
|
+
*/
|
|
80
|
+
function template(name: string): string {
|
|
81
|
+
return colorize(name, COLORS.template)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Highlight a file name or path
|
|
86
|
+
* @example highlight.file("AGENTS.md") -> "\x1b[96mAGENTS.md\x1b[0m"
|
|
87
|
+
*/
|
|
88
|
+
function file(name: string): string {
|
|
89
|
+
return colorize(name, COLORS.file)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Highlight a setting name
|
|
94
|
+
* @example highlight.setting("agency.template") -> "\x1b[96magency.template\x1b[0m"
|
|
95
|
+
*/
|
|
96
|
+
function setting(name: string): string {
|
|
97
|
+
return colorize(name, COLORS.setting)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Highlight a numeric value or count
|
|
102
|
+
* @example highlight.value("3") -> "\x1b[96m3\x1b[0m"
|
|
103
|
+
*/
|
|
104
|
+
function value(val: string | number): string {
|
|
105
|
+
return colorize(String(val), COLORS.value)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Highlight a commit hash
|
|
110
|
+
* @example highlight.commit("abc123") -> "\x1b[96mabc123\x1b[0m"
|
|
111
|
+
*/
|
|
112
|
+
function commit(hash: string): string {
|
|
113
|
+
return colorize(hash, COLORS.commit)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Highlight a pattern or placeholder
|
|
118
|
+
* @example highlight.pattern("{task}") -> "\x1b[96m{task}\x1b[0m"
|
|
119
|
+
*/
|
|
120
|
+
function pattern(text: string): string {
|
|
121
|
+
return colorize(text, COLORS.pattern)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Highlight a git remote name
|
|
126
|
+
* @example highlight.remote("origin") -> "\x1b[33morigin\x1b[0m"
|
|
127
|
+
*/
|
|
128
|
+
function remote(name: string): string {
|
|
129
|
+
return colorize(name, COLORS.remote)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Prepends a green checkmark to a message for success output
|
|
134
|
+
* Uses the same checkmark symbol and color as ora for consistency
|
|
135
|
+
* @param message - The message to prepend the checkmark to
|
|
136
|
+
* @example log(done(`Merged ${highlight.branch("main")}`))
|
|
137
|
+
* @example log(done("Operation completed"))
|
|
138
|
+
*/
|
|
139
|
+
export function done(message: string): string {
|
|
140
|
+
const checkmark = colorsEnabled ? `\x1b[32m✔\x1b[0m` : "✔"
|
|
141
|
+
return `${checkmark} ${message}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Prepends an info icon to a message for informational output
|
|
146
|
+
* @param message - The message to prepend the info icon to
|
|
147
|
+
* @example log(info("Skipping task description"))
|
|
148
|
+
* @example log(info(`File already exists`))
|
|
149
|
+
*/
|
|
150
|
+
export function info(message: string): string {
|
|
151
|
+
return `ⓘ ${message}`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Returns "s" if count is not 1, empty string otherwise
|
|
156
|
+
* Useful for pluralizing words in messages
|
|
157
|
+
* @param count - The count to check
|
|
158
|
+
* @example `Committed ${count} file${plural(count)}` -> "Committed 1 file" or "Committed 3 files"
|
|
159
|
+
*/
|
|
160
|
+
export function plural(count: number): string {
|
|
161
|
+
return count === 1 ? "" : "s"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Default export as namespace for convenient usage
|
|
166
|
+
* @example import highlight from "./colors"
|
|
167
|
+
* @example console.log(`Switched to ${highlight.branch("main")}`)
|
|
168
|
+
*/
|
|
169
|
+
export default {
|
|
170
|
+
branch,
|
|
171
|
+
template,
|
|
172
|
+
file,
|
|
173
|
+
setting,
|
|
174
|
+
value,
|
|
175
|
+
commit,
|
|
176
|
+
pattern,
|
|
177
|
+
remote,
|
|
178
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base options that all commands accept
|
|
3
|
+
*/
|
|
4
|
+
export interface BaseCommandOptions {
|
|
5
|
+
readonly silent?: boolean
|
|
6
|
+
readonly verbose?: boolean
|
|
7
|
+
/**
|
|
8
|
+
* Working directory to use instead of process.cwd().
|
|
9
|
+
* Primarily used for testing to enable concurrent test execution.
|
|
10
|
+
*/
|
|
11
|
+
readonly cwd?: string
|
|
12
|
+
/**
|
|
13
|
+
* Agency config directory to use instead of AGENCY_CONFIG_DIR env var.
|
|
14
|
+
* Primarily used for testing to enable concurrent test execution.
|
|
15
|
+
*/
|
|
16
|
+
readonly configDir?: string
|
|
17
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { GitService } from "../services/GitService"
|
|
3
|
+
import { getBaseBranchFromMetadata } from "../types"
|
|
4
|
+
import highlight from "./colors"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ensure a branch exists, failing with an error if it doesn't
|
|
8
|
+
*/
|
|
9
|
+
export function ensureBranchExists(
|
|
10
|
+
gitRoot: string,
|
|
11
|
+
branch: string,
|
|
12
|
+
errorMessage?: string,
|
|
13
|
+
) {
|
|
14
|
+
return Effect.gen(function* () {
|
|
15
|
+
const git = yield* GitService
|
|
16
|
+
const exists = yield* git.branchExists(gitRoot, branch)
|
|
17
|
+
if (!exists) {
|
|
18
|
+
return yield* Effect.fail(
|
|
19
|
+
new Error(
|
|
20
|
+
errorMessage ?? `Branch ${highlight.branch(branch)} does not exist`,
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create logging functions based on options
|
|
29
|
+
*/
|
|
30
|
+
export function createLoggers(options: {
|
|
31
|
+
readonly silent?: boolean
|
|
32
|
+
readonly verbose?: boolean
|
|
33
|
+
}) {
|
|
34
|
+
const { silent = false, verbose = false } = options
|
|
35
|
+
return {
|
|
36
|
+
log: silent ? () => {} : console.log,
|
|
37
|
+
verboseLog: verbose && !silent ? console.log : () => {},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ensure we're in a git repository and return the git root
|
|
43
|
+
* @param providedCwd - Optional working directory to use instead of process.cwd()
|
|
44
|
+
*/
|
|
45
|
+
export function ensureGitRepo(providedCwd?: string) {
|
|
46
|
+
return Effect.gen(function* () {
|
|
47
|
+
const git = yield* GitService
|
|
48
|
+
const cwd = providedCwd ?? process.cwd()
|
|
49
|
+
|
|
50
|
+
const isGitRepo = yield* git.isInsideGitRepo(cwd)
|
|
51
|
+
if (!isGitRepo) {
|
|
52
|
+
return yield* Effect.fail(
|
|
53
|
+
new Error(
|
|
54
|
+
"Not in a git repository. Please run this command inside a git repo.",
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return yield* git.getGitRoot(cwd)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the configured template name for the current repository
|
|
65
|
+
*/
|
|
66
|
+
export function getTemplateName(gitRoot: string) {
|
|
67
|
+
return Effect.flatMap(GitService, (git) =>
|
|
68
|
+
git.getGitConfig("agency.template", gitRoot),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve base branch with fallback chain:
|
|
74
|
+
* 1. Explicitly provided base branch
|
|
75
|
+
* 2. Branch-specific base branch from agency.json
|
|
76
|
+
* 3. Repository-level default base branch from git config
|
|
77
|
+
* 4. Auto-detected from origin/HEAD or common branches
|
|
78
|
+
*/
|
|
79
|
+
export function resolveBaseBranch(
|
|
80
|
+
gitRoot: string,
|
|
81
|
+
providedBaseBranch?: string,
|
|
82
|
+
) {
|
|
83
|
+
return Effect.gen(function* () {
|
|
84
|
+
const git = yield* GitService
|
|
85
|
+
|
|
86
|
+
// If explicitly provided, use it
|
|
87
|
+
if (providedBaseBranch) {
|
|
88
|
+
yield* ensureBranchExists(gitRoot, providedBaseBranch)
|
|
89
|
+
return providedBaseBranch
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check if we have a branch-specific base branch in agency.json
|
|
93
|
+
const savedBaseBranch = yield* Effect.tryPromise({
|
|
94
|
+
try: () => getBaseBranchFromMetadata(gitRoot),
|
|
95
|
+
catch: (error) =>
|
|
96
|
+
new Error(`Failed to get base branch from metadata: ${error}`),
|
|
97
|
+
})
|
|
98
|
+
if (savedBaseBranch) {
|
|
99
|
+
const exists = yield* git.branchExists(gitRoot, savedBaseBranch)
|
|
100
|
+
if (exists) {
|
|
101
|
+
return savedBaseBranch
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check for repository-level default base branch in git config
|
|
106
|
+
const defaultBaseBranch = yield* git.getDefaultBaseBranchConfig(gitRoot)
|
|
107
|
+
if (defaultBaseBranch) {
|
|
108
|
+
const exists = yield* git.branchExists(gitRoot, defaultBaseBranch)
|
|
109
|
+
if (exists) {
|
|
110
|
+
return defaultBaseBranch
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Try to auto-detect the default remote branch
|
|
115
|
+
const defaultRemote = yield* git.getDefaultRemoteBranch(gitRoot)
|
|
116
|
+
if (defaultRemote) {
|
|
117
|
+
const exists = yield* git.branchExists(gitRoot, defaultRemote)
|
|
118
|
+
if (exists) {
|
|
119
|
+
return defaultRemote
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Try common base branches in order, using resolved remote
|
|
124
|
+
const remote = yield* git
|
|
125
|
+
.resolveRemote(gitRoot)
|
|
126
|
+
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
127
|
+
|
|
128
|
+
const commonBases: string[] = []
|
|
129
|
+
if (remote) {
|
|
130
|
+
commonBases.push(`${remote}/main`, `${remote}/master`)
|
|
131
|
+
}
|
|
132
|
+
commonBases.push("main", "master")
|
|
133
|
+
|
|
134
|
+
for (const base of commonBases) {
|
|
135
|
+
const exists = yield* git.branchExists(gitRoot, base)
|
|
136
|
+
if (exists) {
|
|
137
|
+
return base
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Could not auto-detect, require explicit specification
|
|
142
|
+
return yield* Effect.fail(
|
|
143
|
+
new Error(
|
|
144
|
+
"Could not auto-detect base branch. Please specify one explicitly with the --base-branch option or configure one with: agency base set <branch>",
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get base branch from agency.json metadata as an Effect
|
|
152
|
+
*/
|
|
153
|
+
export function getBaseBranchFromMetadataEffect(gitRoot: string) {
|
|
154
|
+
return Effect.tryPromise({
|
|
155
|
+
try: () => getBaseBranchFromMetadata(gitRoot),
|
|
156
|
+
catch: (error) =>
|
|
157
|
+
new Error(`Failed to get base branch from metadata: ${error}`),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get base branch from agency.json on a specific branch without checking it out.
|
|
163
|
+
* Uses git show to read the file contents directly.
|
|
164
|
+
*/
|
|
165
|
+
export function getBaseBranchFromBranch(gitRoot: string, branch: string) {
|
|
166
|
+
return Effect.gen(function* () {
|
|
167
|
+
const git = yield* GitService
|
|
168
|
+
|
|
169
|
+
// Use git show to read agency.json from the branch
|
|
170
|
+
const content = yield* git.getFileAtRef(gitRoot, branch, "agency.json")
|
|
171
|
+
|
|
172
|
+
if (!content) {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Parse the content
|
|
177
|
+
const data = yield* Effect.try({
|
|
178
|
+
try: () => JSON.parse(content),
|
|
179
|
+
catch: () => null,
|
|
180
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
181
|
+
|
|
182
|
+
if (!data || typeof data !== "object") {
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return (data as { baseBranch?: string }).baseBranch || null
|
|
187
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get the configured remote name with fallback to auto-detection
|
|
192
|
+
*/
|
|
193
|
+
export function getRemoteName(gitRoot: string) {
|
|
194
|
+
return Effect.gen(function* () {
|
|
195
|
+
const git = yield* GitService
|
|
196
|
+
|
|
197
|
+
// Use the new centralized resolveRemote method
|
|
198
|
+
// This already handles config checking and auto-detection with smart precedence
|
|
199
|
+
const remote = yield* git.resolveRemote(gitRoot)
|
|
200
|
+
|
|
201
|
+
return remote
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Execute an operation that may change branches, with automatic cleanup on interrupt.
|
|
207
|
+
* This ensures that if Ctrl-C is pressed during an operation that changes branches,
|
|
208
|
+
* the user is returned to their original branch.
|
|
209
|
+
*
|
|
210
|
+
* @param gitRoot - The git repository root
|
|
211
|
+
* @param operation - The Effect operation to run
|
|
212
|
+
* @returns The result of the operation
|
|
213
|
+
*/
|
|
214
|
+
export function withBranchProtection<A, E, R>(
|
|
215
|
+
gitRoot: string,
|
|
216
|
+
operation: Effect.Effect<A, E, R>,
|
|
217
|
+
) {
|
|
218
|
+
return Effect.gen(function* () {
|
|
219
|
+
const git = yield* GitService
|
|
220
|
+
|
|
221
|
+
// Store the original branch before any operations
|
|
222
|
+
const originalBranch = yield* git.getCurrentBranch(gitRoot)
|
|
223
|
+
|
|
224
|
+
// Set up SIGINT handler to restore branch on interrupt
|
|
225
|
+
let interrupted = false
|
|
226
|
+
const originalSigintHandler = process.listeners("SIGINT")
|
|
227
|
+
|
|
228
|
+
const cleanup = async () => {
|
|
229
|
+
if (interrupted) return
|
|
230
|
+
interrupted = true
|
|
231
|
+
|
|
232
|
+
// Restore original SIGINT handlers
|
|
233
|
+
process.removeAllListeners("SIGINT")
|
|
234
|
+
for (const handler of originalSigintHandler) {
|
|
235
|
+
process.on("SIGINT", handler as NodeJS.SignalsListener)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Try to restore the original branch
|
|
239
|
+
try {
|
|
240
|
+
const currentBranch = await Effect.runPromise(
|
|
241
|
+
git
|
|
242
|
+
.getCurrentBranch(gitRoot)
|
|
243
|
+
.pipe(Effect.provide(GitService.Default)),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if (currentBranch !== originalBranch) {
|
|
247
|
+
await Effect.runPromise(
|
|
248
|
+
git
|
|
249
|
+
.checkoutBranch(gitRoot, originalBranch)
|
|
250
|
+
.pipe(Effect.provide(GitService.Default)),
|
|
251
|
+
)
|
|
252
|
+
console.error(`\nInterrupted. Restored to branch: ${originalBranch}`)
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
console.error(
|
|
256
|
+
`\nInterrupted. Could not restore branch. You may need to run: git checkout ${originalBranch}`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Exit the process
|
|
261
|
+
process.exit(130) // Standard exit code for SIGINT
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Install our SIGINT handler
|
|
265
|
+
process.removeAllListeners("SIGINT")
|
|
266
|
+
process.on("SIGINT", cleanup)
|
|
267
|
+
|
|
268
|
+
// Run the operation
|
|
269
|
+
const result = yield* Effect.onExit(operation, () =>
|
|
270
|
+
Effect.sync(() => {
|
|
271
|
+
// Restore original SIGINT handlers when operation completes
|
|
272
|
+
process.removeAllListeners("SIGINT")
|
|
273
|
+
for (const handler of originalSigintHandler) {
|
|
274
|
+
process.on("SIGINT", handler as NodeJS.SignalsListener)
|
|
275
|
+
}
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return result
|
|
280
|
+
})
|
|
281
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { dlopen, FFIType, ptr } from "bun:ffi"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native exec implementation using Bun FFI to call POSIX execvp.
|
|
5
|
+
* This completely replaces the current process with the specified command.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: This function will never return if successful. The process
|
|
8
|
+
* image is completely replaced with the new program.
|
|
9
|
+
*
|
|
10
|
+
* @param file - The program to execute (will be searched in PATH)
|
|
11
|
+
* @param args - Array of arguments (first should be the program name)
|
|
12
|
+
* @throws Error if exec fails (e.g., command not found)
|
|
13
|
+
*/
|
|
14
|
+
export function execvp(file: string, args: string[]): never {
|
|
15
|
+
// Open libc to access execvp (platform-specific library paths)
|
|
16
|
+
const libcPath =
|
|
17
|
+
process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6"
|
|
18
|
+
const libc = dlopen(libcPath, {
|
|
19
|
+
execvp: {
|
|
20
|
+
args: [FFIType.cstring, FFIType.ptr],
|
|
21
|
+
returns: FFIType.int,
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// execvp expects argv as a null-terminated array of char* pointers
|
|
26
|
+
// We need to convert our string array to C strings and create a pointer array
|
|
27
|
+
const cstrings = args.map((arg) => Buffer.from(arg + "\0"))
|
|
28
|
+
const ptrs = new BigUint64Array(args.length + 1)
|
|
29
|
+
|
|
30
|
+
// Fill the pointer array with addresses of our C strings
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const buf = cstrings[i]
|
|
33
|
+
if (buf) {
|
|
34
|
+
ptrs[i] = BigInt(ptr(buf))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Null-terminate the pointer array
|
|
38
|
+
ptrs[args.length] = 0n
|
|
39
|
+
|
|
40
|
+
// Call execvp - this will replace the current process if successful
|
|
41
|
+
const fileBuffer = Buffer.from(file + "\0")
|
|
42
|
+
const result = libc.symbols.execvp(ptr(fileBuffer), ptr(ptrs))
|
|
43
|
+
|
|
44
|
+
// If we reach here, exec failed
|
|
45
|
+
throw new Error(
|
|
46
|
+
`execvp failed with code ${result}: Unable to execute '${file}'`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for resolving agency configuration paths.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the agency configuration directory.
|
|
10
|
+
* Defaults to ~/.config/agency, can be overridden via AGENCY_CONFIG_DIR env var.
|
|
11
|
+
* @param override - Optional override for the config directory (used in testing)
|
|
12
|
+
*/
|
|
13
|
+
export function getAgencyConfigDir(override?: string): string {
|
|
14
|
+
return (
|
|
15
|
+
override ||
|
|
16
|
+
process.env.AGENCY_CONFIG_DIR ||
|
|
17
|
+
join(homedir(), ".config", "agency")
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the path to the agency config file (agency.json).
|
|
23
|
+
* Can be overridden via AGENCY_CONFIG_PATH env var.
|
|
24
|
+
* @param configDir - Optional config directory override
|
|
25
|
+
*/
|
|
26
|
+
export function getAgencyConfigPath(configDir?: string): string {
|
|
27
|
+
return (
|
|
28
|
+
process.env.AGENCY_CONFIG_PATH ||
|
|
29
|
+
join(getAgencyConfigDir(configDir), "agency.json")
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the templates directory path.
|
|
35
|
+
* @param configDir - Optional config directory override
|
|
36
|
+
*/
|
|
37
|
+
export function getTemplatesDir(configDir?: string): string {
|
|
38
|
+
return join(getAgencyConfigDir(configDir), "templates")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the path to a specific template directory.
|
|
43
|
+
* @param templateName - Name of the template
|
|
44
|
+
* @param configDir - Optional config directory override
|
|
45
|
+
*/
|
|
46
|
+
export function getTemplateDir(
|
|
47
|
+
templateName: string,
|
|
48
|
+
configDir?: string,
|
|
49
|
+
): string {
|
|
50
|
+
return join(getTemplatesDir(configDir), templateName)
|
|
51
|
+
}
|