@markjaquith/agency 1.11.1 → 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.
Files changed (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +258 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +172 -419
  24. package/src/commands/work.ts +47 -166
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/worktree-command.test.ts +61 -0
  53. package/src/workbase/worktree-command.ts +52 -0
  54. package/src/commands/base.test.ts +0 -198
  55. package/src/commands/base.ts +0 -198
  56. package/src/commands/clean.test.ts +0 -299
  57. package/src/commands/clean.ts +0 -274
  58. package/src/commands/completions.test.ts +0 -51
  59. package/src/commands/completions.ts +0 -308
  60. package/src/commands/emit.integration.test.ts +0 -549
  61. package/src/commands/emit.test.ts +0 -576
  62. package/src/commands/emit.ts +0 -700
  63. package/src/commands/emitted.test.ts +0 -226
  64. package/src/commands/emitted.ts +0 -57
  65. package/src/commands/loop.test.ts +0 -621
  66. package/src/commands/loop.ts +0 -406
  67. package/src/commands/merge.integration.test.ts +0 -195
  68. package/src/commands/merge.test.ts +0 -246
  69. package/src/commands/merge.ts +0 -244
  70. package/src/commands/pull.test.ts +0 -395
  71. package/src/commands/pull.ts +0 -205
  72. package/src/commands/push.test.ts +0 -641
  73. package/src/commands/push.ts +0 -482
  74. package/src/commands/rebase.test.ts +0 -521
  75. package/src/commands/rebase.ts +0 -232
  76. package/src/commands/save.test.ts +0 -247
  77. package/src/commands/save.ts +0 -162
  78. package/src/commands/source.test.ts +0 -195
  79. package/src/commands/source.ts +0 -72
  80. package/src/commands/switch.test.ts +0 -194
  81. package/src/commands/switch.ts +0 -84
  82. package/src/commands/task-branching.test.ts +0 -633
  83. package/src/commands/task-continue.test.ts +0 -311
  84. package/src/commands/task-edit.test.ts +0 -235
  85. package/src/commands/task-main.test.ts +0 -1066
  86. package/src/commands/task-squash.test.ts +0 -266
  87. package/src/commands/tasks.test.ts +0 -391
  88. package/src/commands/tasks.ts +0 -161
  89. package/src/commands/template-delete.test.ts +0 -178
  90. package/src/commands/template-delete.ts +0 -98
  91. package/src/commands/template-list.test.ts +0 -135
  92. package/src/commands/template-list.ts +0 -87
  93. package/src/commands/template-view.test.ts +0 -158
  94. package/src/commands/template-view.ts +0 -86
  95. package/src/commands/template.test.ts +0 -32
  96. package/src/commands/template.ts +0 -96
  97. package/src/commands/use.test.ts +0 -87
  98. package/src/commands/use.ts +0 -97
  99. package/src/constants.ts +0 -10
  100. package/src/errors.ts +0 -17
  101. package/src/schemas.ts +0 -33
  102. package/src/services/AgencyMetadataService.ts +0 -311
  103. package/src/services/ClaudeService.test.ts +0 -184
  104. package/src/services/ClaudeService.ts +0 -91
  105. package/src/services/ConfigService.ts +0 -115
  106. package/src/services/FilterRepoService.ts +0 -164
  107. package/src/services/FormatterService.test.ts +0 -432
  108. package/src/services/FormatterService.ts +0 -219
  109. package/src/services/GitService.ts +0 -1187
  110. package/src/services/MockFilterRepoService.ts +0 -140
  111. package/src/services/OpencodeService.ts +0 -263
  112. package/src/services/PromptService.ts +0 -183
  113. package/src/services/TemplateService.ts +0 -81
  114. package/src/types/native-exec.d.ts +0 -8
  115. package/src/utils/colors.ts +0 -178
  116. package/src/utils/git-path.ts +0 -6
  117. package/src/utils/glob.test.ts +0 -161
  118. package/src/utils/glob.ts +0 -78
  119. package/src/utils/paths.ts +0 -51
  120. package/src/utils/pr-branch.test.ts +0 -376
  121. package/src/utils/pr-branch.ts +0 -508
  122. package/src/utils/spinner.test.ts +0 -45
  123. package/src/utils/spinner.ts +0 -96
  124. package/src/utils/task-parser.test.ts +0 -172
  125. package/src/utils/task-parser.ts +0 -93
  126. package/templates/AGENCY.md +0 -22
  127. package/templates/AGENTS.md +0 -11
  128. package/templates/CLAUDE.md +0 -3
  129. package/templates/TASK.md +0 -5
  130. package/templates/opencode.json +0 -4
@@ -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
+ })
@@ -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
  })
@@ -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
- ]).finally(() => {
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)