@markjaquith/agency 1.11.1 → 2.1.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 (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -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 +326 -399
  24. package/src/commands/work.ts +153 -153
  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/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. 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)