@markjaquith/agency 1.6.3 → 1.7.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/cli.ts +23 -2
- package/package.json +1 -1
- package/src/commands/clean.test.ts +2 -2
- package/src/commands/clean.ts +3 -20
- package/src/commands/emit.integration.test.ts +17 -17
- package/src/commands/emit.test.ts +22 -22
- package/src/commands/emit.ts +8 -7
- package/src/commands/emitted.test.ts +8 -8
- package/src/commands/emitted.ts +1 -1
- package/src/commands/merge.integration.test.ts +5 -5
- package/src/commands/merge.test.ts +7 -7
- package/src/commands/pull.test.ts +15 -15
- package/src/commands/push.test.ts +30 -30
- package/src/commands/rebase.test.ts +19 -19
- package/src/commands/rebase.ts +5 -8
- package/src/commands/source.test.ts +13 -13
- package/src/commands/status.test.ts +8 -8
- package/src/commands/status.ts +4 -26
- package/src/commands/switch.test.ts +18 -18
- package/src/commands/switch.ts +3 -3
- package/src/commands/task-branching.test.ts +19 -19
- package/src/commands/task-continue.test.ts +3 -3
- package/src/commands/task-main.test.ts +7 -7
- package/src/commands/task.ts +14 -7
- package/src/constants.ts +10 -0
- package/src/schemas.ts +1 -1
- package/src/services/AgencyMetadataService.ts +217 -242
- package/src/services/ConfigService.ts +1 -1
- package/src/services/FormatterService.test.ts +432 -0
- package/src/services/FormatterService.ts +219 -0
- package/src/services/TemplateService.ts +9 -3
- package/src/test-utils.ts +3 -0
- package/src/types.ts +6 -9
- package/src/utils/pr-branch.test.ts +36 -32
- package/src/utils/pr-branch.ts +12 -15
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { Effect } from "effect"
|
|
4
|
+
import { FormatterService } from "../services/FormatterService"
|
|
5
|
+
import { createTempDir, cleanupTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
|
|
7
|
+
describe("FormatterService", () => {
|
|
8
|
+
let tempDir: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = await createTempDir()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await cleanupTempDir(tempDir)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("detectPackageManager", () => {
|
|
19
|
+
test("returns null when no package.json exists", async () => {
|
|
20
|
+
const result = await runTestEffect(
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const service = yield* FormatterService
|
|
23
|
+
return yield* service.detectPackageManager(tempDir)
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
expect(result).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("detects bun from bun.lockb", async () => {
|
|
30
|
+
await Bun.write(join(tempDir, "package.json"), "{}")
|
|
31
|
+
await Bun.write(join(tempDir, "bun.lockb"), "")
|
|
32
|
+
|
|
33
|
+
const result = await runTestEffect(
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const service = yield* FormatterService
|
|
36
|
+
return yield* service.detectPackageManager(tempDir)
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
expect(result).toBe("bun")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("detects bun from bun.lock", async () => {
|
|
43
|
+
await Bun.write(join(tempDir, "package.json"), "{}")
|
|
44
|
+
await Bun.write(join(tempDir, "bun.lock"), "")
|
|
45
|
+
|
|
46
|
+
const result = await runTestEffect(
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const service = yield* FormatterService
|
|
49
|
+
return yield* service.detectPackageManager(tempDir)
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
expect(result).toBe("bun")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("detects yarn from yarn.lock", async () => {
|
|
56
|
+
await Bun.write(join(tempDir, "package.json"), "{}")
|
|
57
|
+
await Bun.write(join(tempDir, "yarn.lock"), "")
|
|
58
|
+
|
|
59
|
+
const result = await runTestEffect(
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
const service = yield* FormatterService
|
|
62
|
+
return yield* service.detectPackageManager(tempDir)
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
expect(result).toBe("yarn")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("detects pnpm from pnpm-lock.yaml", async () => {
|
|
69
|
+
await Bun.write(join(tempDir, "package.json"), "{}")
|
|
70
|
+
await Bun.write(join(tempDir, "pnpm-lock.yaml"), "")
|
|
71
|
+
|
|
72
|
+
const result = await runTestEffect(
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const service = yield* FormatterService
|
|
75
|
+
return yield* service.detectPackageManager(tempDir)
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
expect(result).toBe("pnpm")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("detects npm from package-lock.json", async () => {
|
|
82
|
+
await Bun.write(join(tempDir, "package.json"), "{}")
|
|
83
|
+
await Bun.write(join(tempDir, "package-lock.json"), "{}")
|
|
84
|
+
|
|
85
|
+
const result = await runTestEffect(
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const service = yield* FormatterService
|
|
88
|
+
return yield* service.detectPackageManager(tempDir)
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
expect(result).toBe("npm")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("defaults to npm when package.json exists but no lock file", async () => {
|
|
95
|
+
await Bun.write(join(tempDir, "package.json"), "{}")
|
|
96
|
+
|
|
97
|
+
const result = await runTestEffect(
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const service = yield* FormatterService
|
|
100
|
+
return yield* service.detectPackageManager(tempDir)
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
expect(result).toBe("npm")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("bun.lockb takes priority over yarn.lock", async () => {
|
|
107
|
+
await Bun.write(join(tempDir, "package.json"), "{}")
|
|
108
|
+
await Bun.write(join(tempDir, "bun.lockb"), "")
|
|
109
|
+
await Bun.write(join(tempDir, "yarn.lock"), "")
|
|
110
|
+
|
|
111
|
+
const result = await runTestEffect(
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const service = yield* FormatterService
|
|
114
|
+
return yield* service.detectPackageManager(tempDir)
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
expect(result).toBe("bun")
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe("detectFormatter", () => {
|
|
122
|
+
test("returns null when no package.json exists", async () => {
|
|
123
|
+
const result = await runTestEffect(
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
const service = yield* FormatterService
|
|
126
|
+
return yield* service.detectFormatter(tempDir)
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
expect(result).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("detects prettier in devDependencies", async () => {
|
|
133
|
+
await Bun.write(
|
|
134
|
+
join(tempDir, "package.json"),
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
devDependencies: { prettier: "^3.0.0" },
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const result = await runTestEffect(
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const service = yield* FormatterService
|
|
143
|
+
return yield* service.detectFormatter(tempDir)
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
expect(result).toBe("prettier")
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test("detects prettier in dependencies", async () => {
|
|
150
|
+
await Bun.write(
|
|
151
|
+
join(tempDir, "package.json"),
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
dependencies: { prettier: "^3.0.0" },
|
|
154
|
+
}),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const result = await runTestEffect(
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const service = yield* FormatterService
|
|
160
|
+
return yield* service.detectFormatter(tempDir)
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
expect(result).toBe("prettier")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("detects oxfmt in devDependencies", async () => {
|
|
167
|
+
await Bun.write(
|
|
168
|
+
join(tempDir, "package.json"),
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
devDependencies: { oxfmt: "^0.1.0" },
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
const result = await runTestEffect(
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
const service = yield* FormatterService
|
|
177
|
+
return yield* service.detectFormatter(tempDir)
|
|
178
|
+
}),
|
|
179
|
+
)
|
|
180
|
+
expect(result).toBe("oxfmt")
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("oxfmt takes priority over prettier", async () => {
|
|
184
|
+
await Bun.write(
|
|
185
|
+
join(tempDir, "package.json"),
|
|
186
|
+
JSON.stringify({
|
|
187
|
+
devDependencies: { prettier: "^3.0.0", oxfmt: "^0.1.0" },
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const result = await runTestEffect(
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
const service = yield* FormatterService
|
|
194
|
+
return yield* service.detectFormatter(tempDir)
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
expect(result).toBe("oxfmt")
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("returns null when no formatter is in dependencies", async () => {
|
|
201
|
+
await Bun.write(
|
|
202
|
+
join(tempDir, "package.json"),
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
devDependencies: { typescript: "^5.0.0" },
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const result = await runTestEffect(
|
|
209
|
+
Effect.gen(function* () {
|
|
210
|
+
const service = yield* FormatterService
|
|
211
|
+
return yield* service.detectFormatter(tempDir)
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
expect(result).toBeNull()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test("handles malformed package.json gracefully", async () => {
|
|
218
|
+
await Bun.write(join(tempDir, "package.json"), "not valid json")
|
|
219
|
+
|
|
220
|
+
const result = await runTestEffect(
|
|
221
|
+
Effect.gen(function* () {
|
|
222
|
+
const service = yield* FormatterService
|
|
223
|
+
return yield* service.detectFormatter(tempDir)
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
expect(result).toBeNull()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe("buildFormatterCommand", () => {
|
|
231
|
+
test("builds prettier command with bun", async () => {
|
|
232
|
+
const result = await runTestEffect(
|
|
233
|
+
Effect.gen(function* () {
|
|
234
|
+
const service = yield* FormatterService
|
|
235
|
+
return yield* service.buildFormatterCommand("prettier", "bun", [
|
|
236
|
+
"/path/to/file.md",
|
|
237
|
+
])
|
|
238
|
+
}),
|
|
239
|
+
)
|
|
240
|
+
expect(result).toEqual([
|
|
241
|
+
"bun",
|
|
242
|
+
"x",
|
|
243
|
+
"prettier",
|
|
244
|
+
"--write",
|
|
245
|
+
"/path/to/file.md",
|
|
246
|
+
])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test("builds prettier command with yarn", async () => {
|
|
250
|
+
const result = await runTestEffect(
|
|
251
|
+
Effect.gen(function* () {
|
|
252
|
+
const service = yield* FormatterService
|
|
253
|
+
return yield* service.buildFormatterCommand("prettier", "yarn", [
|
|
254
|
+
"/path/to/file.md",
|
|
255
|
+
])
|
|
256
|
+
}),
|
|
257
|
+
)
|
|
258
|
+
expect(result).toEqual([
|
|
259
|
+
"yarn",
|
|
260
|
+
"dlx",
|
|
261
|
+
"prettier",
|
|
262
|
+
"--write",
|
|
263
|
+
"/path/to/file.md",
|
|
264
|
+
])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test("builds prettier command with pnpm", async () => {
|
|
268
|
+
const result = await runTestEffect(
|
|
269
|
+
Effect.gen(function* () {
|
|
270
|
+
const service = yield* FormatterService
|
|
271
|
+
return yield* service.buildFormatterCommand("prettier", "pnpm", [
|
|
272
|
+
"/path/to/file.md",
|
|
273
|
+
])
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
276
|
+
expect(result).toEqual([
|
|
277
|
+
"pnpm",
|
|
278
|
+
"exec",
|
|
279
|
+
"prettier",
|
|
280
|
+
"--write",
|
|
281
|
+
"/path/to/file.md",
|
|
282
|
+
])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test("builds prettier command with npm", async () => {
|
|
286
|
+
const result = await runTestEffect(
|
|
287
|
+
Effect.gen(function* () {
|
|
288
|
+
const service = yield* FormatterService
|
|
289
|
+
return yield* service.buildFormatterCommand("prettier", "npm", [
|
|
290
|
+
"/path/to/file.md",
|
|
291
|
+
])
|
|
292
|
+
}),
|
|
293
|
+
)
|
|
294
|
+
expect(result).toEqual(["npx", "prettier", "--write", "/path/to/file.md"])
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test("builds oxfmt command with bun", async () => {
|
|
298
|
+
const result = await runTestEffect(
|
|
299
|
+
Effect.gen(function* () {
|
|
300
|
+
const service = yield* FormatterService
|
|
301
|
+
return yield* service.buildFormatterCommand("oxfmt", "bun", [
|
|
302
|
+
"/path/to/file.json",
|
|
303
|
+
])
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
306
|
+
expect(result).toEqual(["bun", "x", "oxfmt", "/path/to/file.json"])
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test("handles multiple files", async () => {
|
|
310
|
+
const result = await runTestEffect(
|
|
311
|
+
Effect.gen(function* () {
|
|
312
|
+
const service = yield* FormatterService
|
|
313
|
+
return yield* service.buildFormatterCommand("prettier", "bun", [
|
|
314
|
+
"/path/to/file.md",
|
|
315
|
+
"/path/to/file.json",
|
|
316
|
+
])
|
|
317
|
+
}),
|
|
318
|
+
)
|
|
319
|
+
expect(result).toEqual([
|
|
320
|
+
"bun",
|
|
321
|
+
"x",
|
|
322
|
+
"prettier",
|
|
323
|
+
"--write",
|
|
324
|
+
"/path/to/file.md",
|
|
325
|
+
"/path/to/file.json",
|
|
326
|
+
])
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test("returns null for empty file list", async () => {
|
|
330
|
+
const result = await runTestEffect(
|
|
331
|
+
Effect.gen(function* () {
|
|
332
|
+
const service = yield* FormatterService
|
|
333
|
+
return yield* service.buildFormatterCommand("prettier", "bun", [])
|
|
334
|
+
}),
|
|
335
|
+
)
|
|
336
|
+
expect(result).toBeNull()
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe("formatFiles", () => {
|
|
341
|
+
test("silently does nothing when no package.json exists", async () => {
|
|
342
|
+
const logs: string[] = []
|
|
343
|
+
const verboseLog = (msg: string) => logs.push(msg)
|
|
344
|
+
|
|
345
|
+
await runTestEffect(
|
|
346
|
+
Effect.gen(function* () {
|
|
347
|
+
const service = yield* FormatterService
|
|
348
|
+
yield* service.formatFiles(tempDir, ["AGENTS.md"], verboseLog)
|
|
349
|
+
}),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
expect(logs.some((l) => l.includes("No formatter"))).toBe(true)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test("silently does nothing when no formatter in package.json", async () => {
|
|
356
|
+
await Bun.write(
|
|
357
|
+
join(tempDir, "package.json"),
|
|
358
|
+
JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
const logs: string[] = []
|
|
362
|
+
const verboseLog = (msg: string) => logs.push(msg)
|
|
363
|
+
|
|
364
|
+
await runTestEffect(
|
|
365
|
+
Effect.gen(function* () {
|
|
366
|
+
const service = yield* FormatterService
|
|
367
|
+
yield* service.formatFiles(tempDir, ["AGENTS.md"], verboseLog)
|
|
368
|
+
}),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
expect(logs.some((l) => l.includes("No formatter"))).toBe(true)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test("filters to only md/json/jsonc files", async () => {
|
|
375
|
+
const logs: string[] = []
|
|
376
|
+
const verboseLog = (msg: string) => logs.push(msg)
|
|
377
|
+
|
|
378
|
+
await runTestEffect(
|
|
379
|
+
Effect.gen(function* () {
|
|
380
|
+
const service = yield* FormatterService
|
|
381
|
+
yield* service.formatFiles(
|
|
382
|
+
tempDir,
|
|
383
|
+
["somefile.ts", "other.txt"],
|
|
384
|
+
verboseLog,
|
|
385
|
+
)
|
|
386
|
+
}),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
expect(logs.some((l) => l.includes("No formattable files"))).toBe(true)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test("does nothing with empty file list", async () => {
|
|
393
|
+
const logs: string[] = []
|
|
394
|
+
const verboseLog = (msg: string) => logs.push(msg)
|
|
395
|
+
|
|
396
|
+
await runTestEffect(
|
|
397
|
+
Effect.gen(function* () {
|
|
398
|
+
const service = yield* FormatterService
|
|
399
|
+
yield* service.formatFiles(tempDir, [], verboseLog)
|
|
400
|
+
}),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
expect(logs.some((l) => l.includes("No files to format"))).toBe(true)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test("silently handles formatter command failure", async () => {
|
|
407
|
+
// Set up a project with prettier but no actual prettier installed
|
|
408
|
+
await Bun.write(
|
|
409
|
+
join(tempDir, "package.json"),
|
|
410
|
+
JSON.stringify({ devDependencies: { prettier: "^3.0.0" } }),
|
|
411
|
+
)
|
|
412
|
+
await Bun.write(join(tempDir, "bun.lockb"), "")
|
|
413
|
+
await Bun.write(join(tempDir, "AGENTS.md"), "# Test")
|
|
414
|
+
|
|
415
|
+
const logs: string[] = []
|
|
416
|
+
const verboseLog = (msg: string) => logs.push(msg)
|
|
417
|
+
|
|
418
|
+
// Should not throw even though prettier isn't actually installed
|
|
419
|
+
await runTestEffect(
|
|
420
|
+
Effect.gen(function* () {
|
|
421
|
+
const service = yield* FormatterService
|
|
422
|
+
yield* service.formatFiles(tempDir, ["AGENTS.md"], verboseLog)
|
|
423
|
+
}),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
// Should have detected the formatter and attempted to run it
|
|
427
|
+
expect(logs.some((l) => l.includes("Detected formatter: prettier"))).toBe(
|
|
428
|
+
true,
|
|
429
|
+
)
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { resolve } from "path"
|
|
3
|
+
import { FileSystemService } from "./FileSystemService"
|
|
4
|
+
|
|
5
|
+
type PackageJson = {
|
|
6
|
+
dependencies?: Record<string, string>
|
|
7
|
+
devDependencies?: Record<string, string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type PackageManager = "bun" | "yarn" | "pnpm" | "npm"
|
|
11
|
+
|
|
12
|
+
type Formatter = "prettier" | "oxfmt"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Service for detecting and running code formatters on agency-created files.
|
|
16
|
+
*
|
|
17
|
+
* When agency creates files (markdown, json), pre-commit or pre-push hooks
|
|
18
|
+
* in the target project may fail if those files aren't formatted according
|
|
19
|
+
* to the project's formatter settings. This service detects the project's
|
|
20
|
+
* formatter (prettier or oxfmt) and package manager, then runs the formatter
|
|
21
|
+
* on the specified files. Failures are silently ignored.
|
|
22
|
+
*/
|
|
23
|
+
export class FormatterService extends Effect.Service<FormatterService>()(
|
|
24
|
+
"FormatterService",
|
|
25
|
+
{
|
|
26
|
+
sync: () => ({
|
|
27
|
+
/**
|
|
28
|
+
* Detect which package manager the project uses by checking for lock files.
|
|
29
|
+
* Falls back to npm if no lock file is found but package.json exists.
|
|
30
|
+
* Returns null if no package.json exists.
|
|
31
|
+
*/
|
|
32
|
+
detectPackageManager: (gitRoot: string) =>
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const fs = yield* FileSystemService
|
|
35
|
+
|
|
36
|
+
// Check for package.json first - if it doesn't exist, no package manager
|
|
37
|
+
const hasPackageJson = yield* fs.exists(
|
|
38
|
+
resolve(gitRoot, "package.json"),
|
|
39
|
+
)
|
|
40
|
+
if (!hasPackageJson) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check lock files in priority order
|
|
45
|
+
if (yield* fs.exists(resolve(gitRoot, "bun.lockb")))
|
|
46
|
+
return "bun" as PackageManager
|
|
47
|
+
if (yield* fs.exists(resolve(gitRoot, "bun.lock")))
|
|
48
|
+
return "bun" as PackageManager
|
|
49
|
+
if (yield* fs.exists(resolve(gitRoot, "yarn.lock")))
|
|
50
|
+
return "yarn" as PackageManager
|
|
51
|
+
if (yield* fs.exists(resolve(gitRoot, "pnpm-lock.yaml")))
|
|
52
|
+
return "pnpm" as PackageManager
|
|
53
|
+
if (yield* fs.exists(resolve(gitRoot, "package-lock.json")))
|
|
54
|
+
return "npm" as PackageManager
|
|
55
|
+
|
|
56
|
+
// Default to npm if package.json exists but no lock file found
|
|
57
|
+
return "npm" as PackageManager
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detect which formatter is available in the project's package.json.
|
|
62
|
+
* Checks both dependencies and devDependencies for prettier and oxfmt.
|
|
63
|
+
* Returns null if neither is found.
|
|
64
|
+
*/
|
|
65
|
+
detectFormatter: (gitRoot: string) =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const fs = yield* FileSystemService
|
|
68
|
+
|
|
69
|
+
const packageJsonPath = resolve(gitRoot, "package.json")
|
|
70
|
+
const exists = yield* fs.exists(packageJsonPath)
|
|
71
|
+
if (!exists) {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const packageJson = yield* fs.readJSON<PackageJson>(packageJsonPath)
|
|
76
|
+
|
|
77
|
+
const allDeps = {
|
|
78
|
+
...packageJson.dependencies,
|
|
79
|
+
...packageJson.devDependencies,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check for oxfmt first (newer/faster), then prettier
|
|
83
|
+
if ("oxfmt" in allDeps) return "oxfmt" as Formatter
|
|
84
|
+
if ("prettier" in allDeps) return "prettier" as Formatter
|
|
85
|
+
|
|
86
|
+
return null
|
|
87
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null))),
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the command to run a formatter via the detected package manager.
|
|
91
|
+
* Returns the command args array, or null if the formatter/pm combo isn't supported.
|
|
92
|
+
*/
|
|
93
|
+
buildFormatterCommand: (
|
|
94
|
+
formatter: Formatter,
|
|
95
|
+
packageManager: PackageManager,
|
|
96
|
+
files: string[],
|
|
97
|
+
) =>
|
|
98
|
+
Effect.sync(() => {
|
|
99
|
+
if (files.length === 0) return null
|
|
100
|
+
|
|
101
|
+
// Build the runner prefix based on package manager
|
|
102
|
+
const runnerPrefix: string[] = (() => {
|
|
103
|
+
switch (packageManager) {
|
|
104
|
+
case "bun":
|
|
105
|
+
return ["bun", "x"]
|
|
106
|
+
case "yarn":
|
|
107
|
+
return ["yarn", "dlx"]
|
|
108
|
+
case "pnpm":
|
|
109
|
+
return ["pnpm", "exec"]
|
|
110
|
+
case "npm":
|
|
111
|
+
return ["npx"]
|
|
112
|
+
}
|
|
113
|
+
})()
|
|
114
|
+
|
|
115
|
+
// Build the formatter command
|
|
116
|
+
switch (formatter) {
|
|
117
|
+
case "prettier":
|
|
118
|
+
return [...runnerPrefix, "prettier", "--write", ...files]
|
|
119
|
+
case "oxfmt":
|
|
120
|
+
return [...runnerPrefix, "oxfmt", ...files]
|
|
121
|
+
default:
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Format the given files using the project's detected formatter.
|
|
128
|
+
* Silently returns if no formatter is detected or if the command fails.
|
|
129
|
+
* This is the main entry point for formatting agency-created files.
|
|
130
|
+
*/
|
|
131
|
+
formatFiles: (
|
|
132
|
+
gitRoot: string,
|
|
133
|
+
files: string[],
|
|
134
|
+
verboseLog: (msg: string) => void,
|
|
135
|
+
) =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const fs = yield* FileSystemService
|
|
138
|
+
|
|
139
|
+
if (files.length === 0) {
|
|
140
|
+
verboseLog("No files to format")
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Filter to only .md and .json/.jsonc files (the types agency creates)
|
|
145
|
+
const formattableFiles = files.filter(
|
|
146
|
+
(f) =>
|
|
147
|
+
f.endsWith(".md") || f.endsWith(".json") || f.endsWith(".jsonc"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if (formattableFiles.length === 0) {
|
|
151
|
+
verboseLog("No formattable files (md/json/jsonc) found")
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Use yield* on the service methods via self-reference through the service tag
|
|
156
|
+
const formatterService = yield* FormatterService
|
|
157
|
+
|
|
158
|
+
const formatter = yield* formatterService.detectFormatter(gitRoot)
|
|
159
|
+
if (!formatter) {
|
|
160
|
+
verboseLog("No formatter (prettier/oxfmt) detected in package.json")
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const packageManager =
|
|
165
|
+
yield* formatterService.detectPackageManager(gitRoot)
|
|
166
|
+
if (!packageManager) {
|
|
167
|
+
verboseLog("No package manager detected")
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
verboseLog(
|
|
172
|
+
`Detected formatter: ${formatter}, package manager: ${packageManager}`,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// Build absolute paths for the files
|
|
176
|
+
const absolutePaths = formattableFiles.map((f) => resolve(gitRoot, f))
|
|
177
|
+
|
|
178
|
+
const command = yield* formatterService.buildFormatterCommand(
|
|
179
|
+
formatter,
|
|
180
|
+
packageManager,
|
|
181
|
+
absolutePaths,
|
|
182
|
+
)
|
|
183
|
+
if (!command) {
|
|
184
|
+
verboseLog("Could not build formatter command")
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
verboseLog(`Running formatter: ${command.join(" ")}`)
|
|
189
|
+
|
|
190
|
+
// Run the formatter, silently ignoring failures
|
|
191
|
+
yield* fs
|
|
192
|
+
.runCommand(command, {
|
|
193
|
+
cwd: gitRoot,
|
|
194
|
+
captureOutput: true,
|
|
195
|
+
})
|
|
196
|
+
.pipe(
|
|
197
|
+
Effect.catchAll((err) => {
|
|
198
|
+
verboseLog(`Formatter failed (silently ignoring): ${err}`)
|
|
199
|
+
return Effect.void
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
verboseLog(
|
|
204
|
+
`Formatted ${formattableFiles.length} file(s) with ${formatter}`,
|
|
205
|
+
)
|
|
206
|
+
}).pipe(
|
|
207
|
+
// Catch any unexpected errors silently
|
|
208
|
+
Effect.catchAll((err) => {
|
|
209
|
+
verboseLog(`Formatting failed (silently ignoring): ${err}`)
|
|
210
|
+
return Effect.void
|
|
211
|
+
}),
|
|
212
|
+
Effect.catchAllDefect((defect) => {
|
|
213
|
+
verboseLog(`Formatting defect (silently ignoring): ${defect}`)
|
|
214
|
+
return Effect.void
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
) {}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getTemplateDir,
|
|
6
6
|
getTemplatesDir,
|
|
7
7
|
} from "../utils/paths"
|
|
8
|
+
import { FileSystemService } from "./FileSystemService"
|
|
8
9
|
|
|
9
10
|
// Error types for Template operations
|
|
10
11
|
class TemplateError extends Data.TaggedError("TemplateError")<{
|
|
@@ -35,9 +36,14 @@ export class TemplateService extends Effect.Service<TemplateService>()(
|
|
|
35
36
|
}),
|
|
36
37
|
}),
|
|
37
38
|
|
|
38
|
-
createTemplateDir: (
|
|
39
|
-
Effect.
|
|
40
|
-
|
|
39
|
+
createTemplateDir: (templateName: string) =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const fs = yield* FileSystemService
|
|
42
|
+
const templateDir = getTemplateDir(templateName)
|
|
43
|
+
const exists = yield* fs.exists(templateDir)
|
|
44
|
+
if (!exists) {
|
|
45
|
+
yield* fs.createDirectory(templateDir)
|
|
46
|
+
}
|
|
41
47
|
}),
|
|
42
48
|
|
|
43
49
|
listTemplates: () =>
|
package/src/test-utils.ts
CHANGED
|
@@ -383,6 +383,7 @@ import { OpencodeService } from "./services/OpencodeService"
|
|
|
383
383
|
import { ClaudeService } from "./services/ClaudeService"
|
|
384
384
|
import { FilterRepoService } from "./services/FilterRepoService"
|
|
385
385
|
import { MockFilterRepoService } from "./services/MockFilterRepoService"
|
|
386
|
+
import { FormatterService } from "./services/FormatterService"
|
|
386
387
|
|
|
387
388
|
// Re-export mock utilities for tests
|
|
388
389
|
export {
|
|
@@ -402,6 +403,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
402
403
|
OpencodeService.Default,
|
|
403
404
|
ClaudeService.Default,
|
|
404
405
|
FilterRepoService.Default,
|
|
406
|
+
FormatterService.Default,
|
|
405
407
|
)
|
|
406
408
|
|
|
407
409
|
// Create test layer with mock filter-repo (for tests that don't need real filtering)
|
|
@@ -414,6 +416,7 @@ const TestLayerWithMockFilterRepo = Layer.mergeAll(
|
|
|
414
416
|
OpencodeService.Default,
|
|
415
417
|
ClaudeService.Default,
|
|
416
418
|
MockFilterRepoService.Default,
|
|
419
|
+
FormatterService.Default,
|
|
417
420
|
)
|
|
418
421
|
|
|
419
422
|
export async function runTestEffect<A, E>(
|