@markjaquith/agency 2.17.0 → 2.19.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.
@@ -31,7 +31,7 @@ describe("workbase command", () => {
31
31
  }),
32
32
  ),
33
33
  )
34
- const path = JSON.parse(added[0]!).path
34
+ const registration = JSON.parse(added[0]!)
35
35
 
36
36
  const listed = await captureLogs(() =>
37
37
  runTestEffect(
@@ -44,7 +44,68 @@ describe("workbase command", () => {
44
44
  ),
45
45
  )
46
46
 
47
- expect(JSON.parse(listed[0]!)).toEqual([path])
47
+ expect(JSON.parse(listed[0]!)).toEqual({
48
+ workbases: [registration],
49
+ })
50
+ })
51
+
52
+ test("sets, clears, and removes the default workbase", async () => {
53
+ const added = await captureLogs(() =>
54
+ runTestEffect(
55
+ workbase({
56
+ subcommand: "add",
57
+ args: [root],
58
+ name: "primary",
59
+ configDirectory,
60
+ json: true,
61
+ }),
62
+ ),
63
+ )
64
+ const registration = JSON.parse(added[0]!)
65
+
66
+ await runTestEffect(
67
+ workbase({
68
+ subcommand: "default",
69
+ args: ["primary"],
70
+ configDirectory,
71
+ silent: true,
72
+ }),
73
+ )
74
+ const listed = await captureLogs(() =>
75
+ runTestEffect(
76
+ workbase({
77
+ subcommand: "list",
78
+ args: [],
79
+ configDirectory,
80
+ json: true,
81
+ }),
82
+ ),
83
+ )
84
+ expect(JSON.parse(listed[0]!).defaultId).toBe(registration.id)
85
+ await runTestEffect(
86
+ workbase({
87
+ subcommand: "default",
88
+ args: [],
89
+ clear: true,
90
+ configDirectory,
91
+ silent: true,
92
+ }),
93
+ )
94
+ expect(
95
+ await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
96
+ ).toEqual({ version: 2, workbases: [registration] })
97
+
98
+ await runTestEffect(
99
+ workbase({
100
+ subcommand: "remove",
101
+ args: [registration.id],
102
+ configDirectory,
103
+ silent: true,
104
+ }),
105
+ )
106
+ expect(
107
+ await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
108
+ ).toEqual({ version: 2, workbases: [] })
48
109
  })
49
110
 
50
111
  test("requires an add path", async () => {
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect"
2
+ import { resolve } from "node:path"
2
3
  import type { BaseCommandOptions } from "../utils/command"
3
4
  import { WorkbaseService } from "../services/WorkbaseService"
4
5
  import { createLoggers } from "../utils/effect"
@@ -7,6 +8,8 @@ interface WorkbaseOptions extends BaseCommandOptions {
7
8
  readonly subcommand?: string
8
9
  readonly args: readonly string[]
9
10
  readonly configDirectory?: string
11
+ readonly name?: string
12
+ readonly clear?: boolean
10
13
  }
11
14
 
12
15
  export const workbase = (options: WorkbaseOptions) =>
@@ -22,26 +25,85 @@ export const workbase = (options: WorkbaseOptions) =>
22
25
  new Error("Usage: agency workbase add <path>"),
23
26
  )
24
27
  }
25
- const root = yield* service.register(path, options.configDirectory)
28
+ const registration = yield* service.register(
29
+ resolve(options.cwd ?? process.cwd(), path),
30
+ options.configDirectory,
31
+ options.name,
32
+ )
26
33
  log(
27
34
  options.json
28
- ? JSON.stringify({ path: root }, null, 2)
29
- : `Added workbase ${root}`,
35
+ ? JSON.stringify(registration, null, 2)
36
+ : `Added workbase ${registration.name ?? registration.id} (${registration.path})`,
30
37
  )
31
38
  return
32
39
  }
33
40
  case "list": {
34
- const workbases = yield* service.listRegistered(options.configDirectory)
41
+ const registrations = yield* service.listRegistrations(
42
+ options.configDirectory,
43
+ )
35
44
  if (options.json) {
36
- log(JSON.stringify(workbases, null, 2))
45
+ log(JSON.stringify(registrations, null, 2))
37
46
  } else {
38
- for (const path of workbases) log(path)
47
+ for (const entry of registrations.workbases) {
48
+ const marker = entry.id === registrations.defaultId ? "*" : " "
49
+ log(`${marker} ${entry.name ?? entry.id}\t${entry.path}`)
50
+ }
39
51
  }
40
52
  return
41
53
  }
54
+ case "remove": {
55
+ const entry = yield* service.removeRegistered(
56
+ options.args[0]!,
57
+ options.configDirectory,
58
+ options.cwd,
59
+ )
60
+ log(
61
+ options.json
62
+ ? JSON.stringify(entry, null, 2)
63
+ : `Removed workbase ${entry.name ?? entry.id}`,
64
+ )
65
+ return
66
+ }
67
+ case "prune": {
68
+ const removed = yield* service.pruneRegistered(options.configDirectory)
69
+ log(
70
+ options.json
71
+ ? JSON.stringify(removed, null, 2)
72
+ : `Pruned ${removed.length} stale workbase${removed.length === 1 ? "" : "s"}`,
73
+ )
74
+ return
75
+ }
76
+ case "default": {
77
+ const selector = options.clear ? null : options.args[0]
78
+ if (selector === undefined) {
79
+ const entry = yield* service.getDefault(options.configDirectory)
80
+ log(
81
+ options.json
82
+ ? JSON.stringify(entry ?? null, null, 2)
83
+ : entry
84
+ ? `${entry.name ?? entry.id}\t${entry.path}`
85
+ : "No default workbase",
86
+ )
87
+ return
88
+ }
89
+ const entry = yield* service.setDefault(
90
+ selector,
91
+ options.configDirectory,
92
+ )
93
+ log(
94
+ options.json
95
+ ? JSON.stringify(entry, null, 2)
96
+ : entry
97
+ ? `Default workbase is ${entry.name ?? entry.id}`
98
+ : "Cleared default workbase",
99
+ )
100
+ return
101
+ }
42
102
  default:
43
103
  return yield* Effect.fail(
44
- new Error("Subcommand is required. Available: add, list"),
104
+ new Error(
105
+ "Subcommand is required. Available: add, list, remove, prune, default",
106
+ ),
45
107
  )
46
108
  }
47
109
  })
@@ -50,9 +112,14 @@ export const help = `
50
112
  Usage: agency workbase <subcommand>
51
113
 
52
114
  Subcommands:
53
- add <path> Register an Agency workbase
54
- list List registered workbases
115
+ add <path> Register an Agency workbase
116
+ list List registered workbases
117
+ remove <selector> Remove a registered workbase
118
+ prune Remove registrations whose paths no longer exist
119
+ default [selector] Show or set the default workbase
55
120
 
56
121
  Options:
57
- --json Output results as JSON
122
+ --name <name> Name a registered workbase
123
+ --clear Clear the default workbase
124
+ --json Output results as JSON
58
125
  `
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, realpath } from "node:fs/promises"
3
+ import { mkdir, realpath, rm, symlink } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { WorkbaseService } from "./WorkbaseService"
@@ -66,11 +66,154 @@ describe("WorkbaseService", () => {
66
66
  ),
67
67
  )
68
68
 
69
- expect(first).toBe(await realpath(workbaseRoot))
70
- expect(registered).toEqual([first])
69
+ expect(first.path).toBe(await realpath(workbaseRoot))
70
+ expect(registered).toEqual([first.path])
71
71
  expect(
72
72
  await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
73
- ).toEqual({ version: 1, workbases: [first] })
73
+ ).toEqual({ version: 2, workbases: [first] })
74
+ })
75
+
76
+ test("resolves names and stable IDs and manages the default", async () => {
77
+ const workbaseRoot = join(root, "workbase")
78
+ const configDirectory = join(root, "config")
79
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
80
+
81
+ const registered = await runTestEffect(
82
+ WorkbaseService.pipe(
83
+ Effect.flatMap((service) =>
84
+ service.register(workbaseRoot, configDirectory, "primary"),
85
+ ),
86
+ ),
87
+ )
88
+ const result = await runTestEffect(
89
+ WorkbaseService.pipe(
90
+ Effect.flatMap((service) =>
91
+ Effect.gen(function* () {
92
+ yield* service.setDefault("primary", configDirectory)
93
+ return {
94
+ byName: yield* service.resolveRegistered(
95
+ "primary",
96
+ configDirectory,
97
+ ),
98
+ byId: yield* service.resolveRegistered(
99
+ registered.id,
100
+ configDirectory,
101
+ ),
102
+ defaultWorkbase: yield* service.getDefault(configDirectory),
103
+ }
104
+ }),
105
+ ),
106
+ ),
107
+ )
108
+
109
+ expect(result.byName).toBe(registered.path)
110
+ expect(result.byId).toBe(registered.path)
111
+ expect(result.defaultWorkbase).toEqual(registered)
112
+ })
113
+
114
+ test("rejects names that collide with stable IDs", async () => {
115
+ const firstRoot = join(root, "first")
116
+ const secondRoot = join(root, "second")
117
+ const configDirectory = join(root, "config")
118
+ await write(firstRoot, "agency.json", '{"version":2}\n')
119
+ await write(secondRoot, "agency.json", '{"version":2}\n')
120
+ const first = await runTestEffect(
121
+ WorkbaseService.pipe(
122
+ Effect.flatMap((service) =>
123
+ service.register(firstRoot, configDirectory),
124
+ ),
125
+ ),
126
+ )
127
+
128
+ await expect(
129
+ runTestEffect(
130
+ WorkbaseService.pipe(
131
+ Effect.flatMap((service) =>
132
+ service.register(secondRoot, configDirectory, first.id),
133
+ ),
134
+ ),
135
+ ),
136
+ ).rejects.toThrow("already registered")
137
+ })
138
+
139
+ test("rejects invalid workbase names before writing the registry", async () => {
140
+ const workbaseRoot = join(root, "workbase")
141
+ const configDirectory = join(root, "config")
142
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
143
+
144
+ for (const name of ["bad/name", ""]) {
145
+ await expect(
146
+ runTestEffect(
147
+ WorkbaseService.pipe(
148
+ Effect.flatMap((service) =>
149
+ service.register(workbaseRoot, configDirectory, name),
150
+ ),
151
+ ),
152
+ ),
153
+ ).rejects.toThrow("Invalid workbase name")
154
+ }
155
+ expect(
156
+ await Bun.file(join(configDirectory, "agency/workbases.json")).exists(),
157
+ ).toBe(false)
158
+ })
159
+
160
+ test("removes a registration by an equivalent symlink path", async () => {
161
+ const workbaseRoot = join(root, "workbase")
162
+ const linkedRoot = join(root, "linked")
163
+ const configDirectory = join(root, "config")
164
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
165
+ await symlink(workbaseRoot, linkedRoot)
166
+ await runTestEffect(
167
+ WorkbaseService.pipe(
168
+ Effect.flatMap((service) =>
169
+ service.register(workbaseRoot, configDirectory),
170
+ ),
171
+ ),
172
+ )
173
+
174
+ const removed = await runTestEffect(
175
+ WorkbaseService.pipe(
176
+ Effect.flatMap((service) =>
177
+ service.removeRegistered(linkedRoot, configDirectory),
178
+ ),
179
+ ),
180
+ )
181
+ expect(removed.path).toBe(await realpath(workbaseRoot))
182
+ })
183
+
184
+ test("migrates legacy registrations and prunes stale paths", async () => {
185
+ const workbaseRoot = join(root, "workbase")
186
+ const staleRoot = join(root, "stale")
187
+ const configDirectory = join(root, "config")
188
+ const registryPath = join(configDirectory, "agency/workbases.json")
189
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
190
+ await write(staleRoot, "agency.json", '{"version":2}\n')
191
+ await write(
192
+ configDirectory,
193
+ "agency/workbases.json",
194
+ JSON.stringify({ version: 1, workbases: [workbaseRoot, staleRoot] }),
195
+ )
196
+ await rm(staleRoot, { recursive: true })
197
+
198
+ const result = await runTestEffect(
199
+ WorkbaseService.pipe(
200
+ Effect.flatMap((service) =>
201
+ Effect.gen(function* () {
202
+ const before = yield* service.listRegistrations(configDirectory)
203
+ const removed = yield* service.pruneRegistered(configDirectory)
204
+ return { before, removed }
205
+ }),
206
+ ),
207
+ ),
208
+ )
209
+
210
+ expect(result.before.workbases).toHaveLength(2)
211
+ expect(result.before.workbases[0]?.id).toStartWith("wb-")
212
+ expect(result.removed.map((entry) => entry.path)).toEqual([staleRoot])
213
+ expect(await Bun.file(registryPath).json()).toEqual({
214
+ version: 2,
215
+ workbases: [result.before.workbases[0]],
216
+ })
74
217
  })
75
218
 
76
219
  test("rejects an invalid worktree command template", async () => {
@@ -6,7 +6,9 @@ import { dirname, join, relative, resolve } from "node:path"
6
6
  import { FileSystemService } from "./FileSystemService"
7
7
  import { parseFrontmatter } from "../workbase/frontmatter"
8
8
  import {
9
+ EntityId,
9
10
  EpicFrontmatter,
11
+ LegacyWorkbaseRegistry,
10
12
  PhaseFrontmatter,
11
13
  TaskFrontmatter,
12
14
  WorkbaseConfig,
@@ -15,6 +17,8 @@ import {
15
17
  type EpicFrontmatter as EpicData,
16
18
  type PhaseFrontmatter as PhaseData,
17
19
  type TaskFrontmatter as TaskData,
20
+ type WorkbaseRegistry as WorkbaseRegistryData,
21
+ type WorkbaseRegistration,
18
22
  } from "../workbase/schemas"
19
23
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
20
24
 
@@ -81,12 +85,17 @@ const registryPath = (configDirectory?: string) =>
81
85
  "workbases.json",
82
86
  )
83
87
 
88
+ const registrationId = (path: string) =>
89
+ `wb-${new Bun.CryptoHasher("sha256").update(path).digest("hex").slice(0, 12)}`
90
+
91
+ const emptyRegistry: WorkbaseRegistryData = { version: 2, workbases: [] }
92
+
84
93
  const readRegistry = (configDirectory?: string) =>
85
94
  Effect.gen(function* () {
86
95
  const fs = yield* FileSystemService
87
96
  const path = registryPath(configDirectory)
88
97
  if (!(yield* fs.exists(path))) {
89
- return { path, registry: { version: 1, workbases: [] } as const }
98
+ return { path, registry: emptyRegistry }
90
99
  }
91
100
 
92
101
  const content = yield* fs.readFile(path)
@@ -102,13 +111,37 @@ const readRegistry = (configDirectory?: string) =>
102
111
  }
103
112
 
104
113
  const decoded = decode(WorkbaseRegistry, input)
105
- if (!decoded.success) {
114
+ if (decoded.success) return { path, registry: decoded.value }
115
+
116
+ const legacy = decode(LegacyWorkbaseRegistry, input)
117
+ if (!legacy.success) {
106
118
  return yield* new WorkbaseRegistryError({
107
119
  path,
108
120
  message: `Invalid workbase registry in ${path}:\n${decoded.error}`,
109
121
  })
110
122
  }
111
- return { path, registry: decoded.value }
123
+ const registry: WorkbaseRegistryData = {
124
+ version: 2 as const,
125
+ workbases: legacy.value.workbases.map((workbasePath) => ({
126
+ id: registrationId(workbasePath),
127
+ path: workbasePath,
128
+ })),
129
+ }
130
+ return { path, registry }
131
+ })
132
+
133
+ const writeRegistry = (
134
+ path: string,
135
+ registry: {
136
+ readonly version: 2
137
+ readonly workbases: readonly WorkbaseRegistration[]
138
+ readonly defaultId?: string
139
+ },
140
+ ) =>
141
+ Effect.gen(function* () {
142
+ const fs = yield* FileSystemService
143
+ yield* fs.createDirectory(dirname(path))
144
+ yield* fs.writeJSON(path, registry)
112
145
  })
113
146
 
114
147
  const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
@@ -285,26 +318,195 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
285
318
  return { root, config: decoded.value }
286
319
  }),
287
320
 
288
- register: (startPath: string, configDirectory?: string) =>
321
+ register: (startPath: string, configDirectory?: string, name?: string) =>
289
322
  Effect.gen(function* () {
290
323
  const service = yield* WorkbaseService
291
324
  const fs = yield* FileSystemService
292
325
  const discovered = yield* service.discover(startPath)
293
326
  const root = yield* fs.realPath(discovered)
294
327
  const { path, registry } = yield* readRegistry(configDirectory)
295
- if (registry.workbases.includes(root)) return root
328
+ if (name !== undefined) {
329
+ const decodedName = decode(EntityId, name)
330
+ if (!decodedName.success) {
331
+ return yield* new WorkbaseRegistryError({
332
+ path,
333
+ message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`,
334
+ })
335
+ }
336
+ }
337
+ const named = name
338
+ ? registry.workbases.find(
339
+ (entry) =>
340
+ (entry.name === name && entry.path !== root) ||
341
+ entry.id === name,
342
+ )
343
+ : undefined
344
+ if (named) {
345
+ return yield* new WorkbaseRegistryError({
346
+ path,
347
+ message: `Workbase name '${name}' is already registered for ${named.path}`,
348
+ })
349
+ }
350
+ const existing = registry.workbases.find(
351
+ (entry) => entry.path === root,
352
+ )
353
+ const registration = {
354
+ id: existing?.id ?? registrationId(root),
355
+ ...(name
356
+ ? { name }
357
+ : existing?.name
358
+ ? { name: existing.name }
359
+ : {}),
360
+ path: root,
361
+ }
362
+ const idNameCollision = registry.workbases.find(
363
+ (entry) => entry.name === registration.id && entry.path !== root,
364
+ )
365
+ if (idNameCollision) {
366
+ return yield* new WorkbaseRegistryError({
367
+ path,
368
+ message: `Workbase ID '${registration.id}' conflicts with the registered name for ${idNameCollision.path}`,
369
+ })
370
+ }
371
+ const workbases = existing
372
+ ? registry.workbases.map((entry) =>
373
+ entry.id === existing.id ? registration : entry,
374
+ )
375
+ : [...registry.workbases, registration]
376
+ yield* writeRegistry(path, { ...registry, workbases })
377
+ return registration
378
+ }),
379
+
380
+ listRegistered: (configDirectory?: string) =>
381
+ readRegistry(configDirectory).pipe(
382
+ Effect.map(({ registry }) =>
383
+ registry.workbases.map((entry) => entry.path),
384
+ ),
385
+ ),
386
+
387
+ listRegistrations: (configDirectory?: string) =>
388
+ readRegistry(configDirectory).pipe(
389
+ Effect.map(({ registry }) => ({
390
+ workbases: registry.workbases,
391
+ defaultId: registry.defaultId,
392
+ })),
393
+ ),
394
+
395
+ resolveRegistered: (
396
+ selector: string,
397
+ configDirectory?: string,
398
+ basePath: string = process.cwd(),
399
+ ) =>
400
+ Effect.gen(function* () {
401
+ const service = yield* WorkbaseService
402
+ const fs = yield* FileSystemService
403
+ const { path, registry } = yield* readRegistry(configDirectory)
404
+ const candidatePath = resolve(basePath, selector)
405
+ const direct =
406
+ registry.workbases.find((entry) => entry.id === selector) ??
407
+ registry.workbases.find((entry) => entry.name === selector) ??
408
+ registry.workbases.find((entry) => entry.path === candidatePath)
409
+ if (direct) return direct.path
410
+ if (yield* fs.exists(candidatePath))
411
+ return yield* service.discover(candidatePath)
412
+ return yield* new WorkbaseRegistryError({
413
+ path,
414
+ message: `Unknown workbase selector '${selector}'`,
415
+ })
416
+ }),
417
+
418
+ removeRegistered: (
419
+ selector: string,
420
+ configDirectory?: string,
421
+ basePath: string = process.cwd(),
422
+ ) =>
423
+ Effect.gen(function* () {
424
+ const fs = yield* FileSystemService
425
+ const { path, registry } = yield* readRegistry(configDirectory)
426
+ const candidatePath = resolve(basePath, selector)
427
+ const canonicalCandidate = (yield* fs.exists(candidatePath))
428
+ ? yield* fs.realPath(candidatePath)
429
+ : candidatePath
430
+ const entry =
431
+ registry.workbases.find((item) => item.id === selector) ??
432
+ registry.workbases.find((item) => item.name === selector) ??
433
+ registry.workbases.find((item) => item.path === canonicalCandidate)
434
+ if (!entry) {
435
+ return yield* new WorkbaseRegistryError({
436
+ path,
437
+ message: `Unknown workbase selector '${selector}'`,
438
+ })
439
+ }
440
+ const workbases = registry.workbases.filter(
441
+ (item) => item.id !== entry.id,
442
+ )
443
+ const next = {
444
+ version: 2 as const,
445
+ workbases,
446
+ ...(registry.defaultId && registry.defaultId !== entry.id
447
+ ? { defaultId: registry.defaultId }
448
+ : {}),
449
+ }
450
+ yield* writeRegistry(path, next)
451
+ return entry
452
+ }),
296
453
 
297
- yield* fs.createDirectory(dirname(path))
298
- yield* fs.writeJSON(path, {
299
- version: 1,
300
- workbases: [...registry.workbases, root],
454
+ pruneRegistered: (configDirectory?: string) =>
455
+ Effect.gen(function* () {
456
+ const fs = yield* FileSystemService
457
+ const { path, registry } = yield* readRegistry(configDirectory)
458
+ const kept: WorkbaseRegistration[] = []
459
+ const removed: WorkbaseRegistration[] = []
460
+ for (const entry of registry.workbases) {
461
+ if (yield* fs.exists(join(entry.path, "agency.json")))
462
+ kept.push(entry)
463
+ else removed.push(entry)
464
+ }
465
+ const defaultId = kept.some(
466
+ (entry) => entry.id === registry.defaultId,
467
+ )
468
+ ? registry.defaultId
469
+ : undefined
470
+ yield* writeRegistry(path, {
471
+ version: 2,
472
+ workbases: kept,
473
+ ...(defaultId ? { defaultId } : {}),
301
474
  })
302
- return root
475
+ return removed
303
476
  }),
304
477
 
305
- listRegistered: (configDirectory?: string) =>
478
+ setDefault: (selector: string | null, configDirectory?: string) =>
479
+ Effect.gen(function* () {
480
+ const { path, registry } = yield* readRegistry(configDirectory)
481
+ if (selector === null) {
482
+ yield* writeRegistry(path, {
483
+ version: 2,
484
+ workbases: registry.workbases,
485
+ })
486
+ return null
487
+ }
488
+ const entry = registry.workbases.find(
489
+ (item) => item.id === selector || item.name === selector,
490
+ )
491
+ if (!entry) {
492
+ return yield* new WorkbaseRegistryError({
493
+ path,
494
+ message: `Unknown registered workbase selector '${selector}'`,
495
+ })
496
+ }
497
+ yield* writeRegistry(path, {
498
+ version: 2,
499
+ workbases: registry.workbases,
500
+ defaultId: entry.id,
501
+ })
502
+ return entry
503
+ }),
504
+
505
+ getDefault: (configDirectory?: string) =>
306
506
  readRegistry(configDirectory).pipe(
307
- Effect.map(({ registry }) => registry.workbases),
507
+ Effect.map(({ registry }) =>
508
+ registry.workbases.find((entry) => entry.id === registry.defaultId),
509
+ ),
308
510
  ),
309
511
 
310
512
  validate: (startPath: string = process.cwd()) =>
@@ -0,0 +1,19 @@
1
+ export const formatTable = (
2
+ headings: readonly string[],
3
+ rows: readonly (readonly string[])[],
4
+ ) => {
5
+ const widths = headings.map((heading, index) =>
6
+ Math.max(heading.length, ...rows.map((row) => row[index]?.length ?? 0)),
7
+ )
8
+ const line = (cells: readonly string[]) =>
9
+ cells
10
+ .map((cell, index) => cell.padEnd(widths[index] ?? cell.length))
11
+ .join(" ")
12
+ .trimEnd()
13
+
14
+ return [
15
+ line(headings),
16
+ line(widths.map((width) => "-".repeat(width))),
17
+ ...rows.map(line),
18
+ ].join("\n")
19
+ }