@markjaquith/agency 1.11.0 → 2.0.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/README.md +302 -113
- package/cli.ts +123 -415
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +258 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +172 -419
- package/src/commands/work.ts +47 -166
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -518
- package/src/commands/push.ts +0 -434
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1183
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- package/templates/opencode.json +0 -4
package/src/utils/command.ts
CHANGED
|
@@ -4,14 +4,10 @@
|
|
|
4
4
|
export interface BaseCommandOptions {
|
|
5
5
|
readonly silent?: boolean
|
|
6
6
|
readonly verbose?: boolean
|
|
7
|
+
readonly json?: boolean
|
|
7
8
|
/**
|
|
8
9
|
* Working directory to use instead of process.cwd().
|
|
9
10
|
* Primarily used for testing to enable concurrent test execution.
|
|
10
11
|
*/
|
|
11
12
|
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
13
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { captureLogs } from "../test-utils"
|
|
3
|
+
import { createLoggers } from "./effect"
|
|
4
|
+
|
|
5
|
+
describe("createLoggers", () => {
|
|
6
|
+
test("keeps JSON output machine-readable when verbose is enabled", async () => {
|
|
7
|
+
const logs = await captureLogs(async () => {
|
|
8
|
+
const { log, verboseLog } = createLoggers({ json: true, verbose: true })
|
|
9
|
+
verboseLog("debug")
|
|
10
|
+
log('{"ok":true}')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
expect(logs).toEqual(['{"ok":true}'])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("lets silent suppress JSON output", async () => {
|
|
17
|
+
const logs = await captureLogs(async () => {
|
|
18
|
+
const { log } = createLoggers({ json: true, silent: true })
|
|
19
|
+
log('{"ok":true}')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
expect(logs).toEqual([])
|
|
23
|
+
})
|
|
24
|
+
})
|
package/src/utils/effect.ts
CHANGED
|
@@ -1,281 +1,11 @@
|
|
|
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
1
|
export function createLoggers(options: {
|
|
31
2
|
readonly silent?: boolean
|
|
32
3
|
readonly verbose?: boolean
|
|
4
|
+
readonly json?: boolean
|
|
33
5
|
}) {
|
|
34
|
-
const { silent = false, verbose = false } = options
|
|
6
|
+
const { silent = false, verbose = false, json = false } = options
|
|
35
7
|
return {
|
|
36
8
|
log: silent ? () => {} : console.log,
|
|
37
|
-
verboseLog: verbose && !silent ? console.log : () => {},
|
|
9
|
+
verboseLog: verbose && !silent && !json ? console.log : () => {},
|
|
38
10
|
}
|
|
39
11
|
}
|
|
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
|
-
}
|
|
@@ -23,19 +23,4 @@ describe("spawnProcess", () => {
|
|
|
23
23
|
expect(result.stderr).toContain("err:0:")
|
|
24
24
|
expect(result.stderr).toContain("err:1999:")
|
|
25
25
|
})
|
|
26
|
-
|
|
27
|
-
test("reports progress while process is still running", async () => {
|
|
28
|
-
const progress: number[] = []
|
|
29
|
-
const script = "await new Promise((resolve) => setTimeout(resolve, 80))"
|
|
30
|
-
|
|
31
|
-
const result = await Effect.runPromise(
|
|
32
|
-
spawnProcess([process.execPath, "-e", script], {
|
|
33
|
-
onProgress: ({ elapsedMs }) => progress.push(elapsedMs),
|
|
34
|
-
progressIntervalMs: 10,
|
|
35
|
-
}),
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
expect(result.exitCode).toBe(0)
|
|
39
|
-
expect(progress.length).toBeGreaterThan(0)
|
|
40
|
-
})
|
|
41
26
|
})
|
package/src/utils/process.ts
CHANGED
|
@@ -18,11 +18,6 @@ interface SpawnOptions {
|
|
|
18
18
|
readonly stdout?: "pipe" | "inherit"
|
|
19
19
|
readonly stderr?: "pipe" | "inherit"
|
|
20
20
|
readonly env?: Record<string, string>
|
|
21
|
-
readonly onProgress?: (progress: {
|
|
22
|
-
readonly elapsedMs: number
|
|
23
|
-
readonly pid?: number
|
|
24
|
-
}) => void
|
|
25
|
-
readonly progressIntervalMs?: number
|
|
26
21
|
}
|
|
27
22
|
|
|
28
23
|
/**
|
|
@@ -59,18 +54,6 @@ export const spawnProcess = (
|
|
|
59
54
|
stderr: options?.stderr ?? "pipe",
|
|
60
55
|
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
61
56
|
})
|
|
62
|
-
const startedAt = Date.now()
|
|
63
|
-
const progressInterval = options?.onProgress
|
|
64
|
-
? setInterval(
|
|
65
|
-
() =>
|
|
66
|
-
options.onProgress?.({
|
|
67
|
-
elapsedMs: Date.now() - startedAt,
|
|
68
|
-
pid: proc.pid,
|
|
69
|
-
}),
|
|
70
|
-
options.progressIntervalMs ?? 10_000,
|
|
71
|
-
)
|
|
72
|
-
: null
|
|
73
|
-
|
|
74
57
|
// Start draining stdout/stderr immediately so verbose subprocesses
|
|
75
58
|
// cannot block on filled pipe buffers before they exit.
|
|
76
59
|
const stdoutPromise =
|
|
@@ -86,11 +69,7 @@ export const spawnProcess = (
|
|
|
86
69
|
proc.exited,
|
|
87
70
|
stdoutPromise,
|
|
88
71
|
stderrPromise,
|
|
89
|
-
])
|
|
90
|
-
if (progressInterval) {
|
|
91
|
-
clearInterval(progressInterval)
|
|
92
|
-
}
|
|
93
|
-
})
|
|
72
|
+
])
|
|
94
73
|
|
|
95
74
|
return {
|
|
96
75
|
stdout: stdout.trim(),
|
|
@@ -106,41 +85,3 @@ export const spawnProcess = (
|
|
|
106
85
|
stderr: error instanceof Error ? error.message : String(error),
|
|
107
86
|
}),
|
|
108
87
|
})
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Helper to check exit code and return stdout on success
|
|
112
|
-
*/
|
|
113
|
-
export const checkExitCodeAndReturnStdout =
|
|
114
|
-
<E>(errorMapper: (result: ProcessResult) => E) =>
|
|
115
|
-
(result: ProcessResult): Effect.Effect<string, E> =>
|
|
116
|
-
result.exitCode === 0
|
|
117
|
-
? Effect.succeed(result.stdout)
|
|
118
|
-
: Effect.fail(errorMapper(result))
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Helper to check exit code and return void on success
|
|
122
|
-
*/
|
|
123
|
-
export const checkExitCodeAndReturnVoid =
|
|
124
|
-
<E>(errorMapper: (result: ProcessResult) => E) =>
|
|
125
|
-
(result: ProcessResult): Effect.Effect<void, E> =>
|
|
126
|
-
result.exitCode === 0 ? Effect.void : Effect.fail(errorMapper(result))
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Helper to create an error mapper function for a specific error type
|
|
130
|
-
* This is useful for wrapping spawnProcess with domain-specific error types
|
|
131
|
-
*/
|
|
132
|
-
export const createErrorMapper =
|
|
133
|
-
<E extends { command: string; exitCode: number; stderr: string }>(
|
|
134
|
-
ErrorConstructor: new (args: {
|
|
135
|
-
command: string
|
|
136
|
-
exitCode: number
|
|
137
|
-
stderr: string
|
|
138
|
-
}) => E,
|
|
139
|
-
) =>
|
|
140
|
-
(args: readonly string[]) =>
|
|
141
|
-
(result: ProcessResult): E =>
|
|
142
|
-
new ErrorConstructor({
|
|
143
|
-
command: args.join(" "),
|
|
144
|
-
exitCode: result.exitCode,
|
|
145
|
-
stderr: result.stderr,
|
|
146
|
-
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { parseFrontmatter } from "./frontmatter"
|
|
4
|
+
|
|
5
|
+
describe("parseFrontmatter", () => {
|
|
6
|
+
test("parses YAML 1.2 frontmatter and body", async () => {
|
|
7
|
+
const parsed = await Effect.runPromise(
|
|
8
|
+
parseFrontmatter(
|
|
9
|
+
"---\nrepo: agency\npr: null\n---\n\n# Task\n",
|
|
10
|
+
"TASK.md",
|
|
11
|
+
),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
expect(parsed.data).toEqual({ repo: "agency", pr: null })
|
|
15
|
+
expect(parsed.body).toBe("\n# Task\n")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("rejects duplicate keys", async () => {
|
|
19
|
+
await expect(
|
|
20
|
+
Effect.runPromise(
|
|
21
|
+
parseFrontmatter("---\nrepo: agency\nrepo: effect\n---\n", "TASK.md"),
|
|
22
|
+
),
|
|
23
|
+
).rejects.toThrow("Map keys must be unique")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("rejects anchors and aliases", async () => {
|
|
27
|
+
await expect(
|
|
28
|
+
Effect.runPromise(
|
|
29
|
+
parseFrontmatter(
|
|
30
|
+
"---\nrepo: &repo agency\nrepos:\n - *repo\n---\n",
|
|
31
|
+
"TASK.md",
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
).rejects.toThrow("not supported")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("rejects custom tags", async () => {
|
|
38
|
+
await expect(
|
|
39
|
+
Effect.runPromise(
|
|
40
|
+
parseFrontmatter("---\nrepo: !custom agency\n---\n", "TASK.md"),
|
|
41
|
+
),
|
|
42
|
+
).rejects.toThrow()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("requires frontmatter at the start of the document", async () => {
|
|
46
|
+
await expect(
|
|
47
|
+
Effect.runPromise(parseFrontmatter("# Task\n", "TASK.md")),
|
|
48
|
+
).rejects.toThrow("must begin with YAML frontmatter")
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { parseDocument, stringify, visit } from "yaml"
|
|
3
|
+
|
|
4
|
+
class FrontmatterParseError extends Data.TaggedError("FrontmatterParseError")<{
|
|
5
|
+
readonly path: string
|
|
6
|
+
readonly message: string
|
|
7
|
+
readonly cause?: unknown
|
|
8
|
+
}> {}
|
|
9
|
+
|
|
10
|
+
interface ParsedFrontmatter {
|
|
11
|
+
readonly data: unknown
|
|
12
|
+
readonly body: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const parseFrontmatter = (content: string, path: string) =>
|
|
16
|
+
Effect.try({
|
|
17
|
+
try: (): ParsedFrontmatter => {
|
|
18
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
|
|
19
|
+
if (!match) {
|
|
20
|
+
throw new Error("Markdown file must begin with YAML frontmatter")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const document = parseDocument(match[1]!, {
|
|
24
|
+
customTags: [],
|
|
25
|
+
merge: false,
|
|
26
|
+
schema: "core",
|
|
27
|
+
strict: true,
|
|
28
|
+
uniqueKeys: true,
|
|
29
|
+
version: "1.2",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const parseMessages = [...document.errors, ...document.warnings]
|
|
33
|
+
if (parseMessages.length > 0) {
|
|
34
|
+
throw new Error(parseMessages.map((error) => error.message).join("; "))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let unsupportedFeature: string | null = null
|
|
38
|
+
visit(document, {
|
|
39
|
+
Alias: () => {
|
|
40
|
+
unsupportedFeature = "YAML aliases are not supported"
|
|
41
|
+
},
|
|
42
|
+
Node: (_key, node) => {
|
|
43
|
+
if (node.anchor) {
|
|
44
|
+
unsupportedFeature = "YAML anchors are not supported"
|
|
45
|
+
} else if (node.tag && !node.tag.startsWith("tag:yaml.org,2002:")) {
|
|
46
|
+
unsupportedFeature = "Custom YAML tags are not supported"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (unsupportedFeature) {
|
|
52
|
+
throw new Error(unsupportedFeature)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const data = document.toJS({ maxAliasCount: 0 })
|
|
56
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
57
|
+
throw new Error("YAML frontmatter must be a mapping")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
data,
|
|
62
|
+
body: content.slice(match[0].length),
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
catch: (cause) =>
|
|
66
|
+
new FrontmatterParseError({
|
|
67
|
+
path,
|
|
68
|
+
message:
|
|
69
|
+
cause instanceof Error
|
|
70
|
+
? cause.message
|
|
71
|
+
: "Failed to parse frontmatter",
|
|
72
|
+
cause,
|
|
73
|
+
}),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export const formatMarkdownDocument = (data: object, body: string) =>
|
|
77
|
+
`---\n${stringify(data, { lineWidth: 0 }).trimEnd()}\n---\n\n${body.trim()}\n`
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { parseRepositoryReferences } from "./repository-reference"
|
|
3
|
+
|
|
4
|
+
describe("repository references", () => {
|
|
5
|
+
test("parses alias and ref CLI values", () => {
|
|
6
|
+
expect(parseRepositoryReferences(["web:origin/main"])[0]).toEqual({
|
|
7
|
+
repo: "web",
|
|
8
|
+
ref: "origin/main",
|
|
9
|
+
})
|
|
10
|
+
expect(
|
|
11
|
+
parseRepositoryReferences(["web:main", "zenpayroll:v1.2.3"]),
|
|
12
|
+
).toEqual([
|
|
13
|
+
{ repo: "web", ref: "main" },
|
|
14
|
+
{ repo: "zenpayroll", ref: "v1.2.3" },
|
|
15
|
+
])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("rejects references without both parts", () => {
|
|
19
|
+
for (const value of ["web", ":main", "web:"]) {
|
|
20
|
+
expect(() => parseRepositoryReferences([value])).toThrow(
|
|
21
|
+
"expected <alias>:<ref>",
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RepositoryReference } from "./schemas"
|
|
2
|
+
|
|
3
|
+
const parseRepositoryReference = (value: string): RepositoryReference => {
|
|
4
|
+
const separator = value.indexOf(":")
|
|
5
|
+
if (separator <= 0 || separator === value.length - 1) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
`Invalid repository reference '${value}'; expected <alias>:<ref>`,
|
|
8
|
+
)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
repo: value.slice(0, separator),
|
|
13
|
+
ref: value.slice(separator + 1),
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const parseRepositoryReferences = (values: readonly string[] = []) =>
|
|
18
|
+
values.map(parseRepositoryReference)
|