@markjaquith/agency 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/cli.ts +74 -8
- package/package.json +1 -1
- package/skills/agency/SKILL.md +13 -6
- package/src/cli-parser.test.ts +74 -0
- package/src/cli-parser.ts +208 -27
- package/src/cli.test.ts +94 -4
- package/src/commands/context.ts +3 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/validate.test.ts +2 -0
- package/src/commands/work.test.ts +1 -0
- package/src/commands/work.ts +15 -7
- package/src/commands/workbase.test.ts +63 -2
- package/src/commands/workbase.ts +77 -10
- package/src/services/WorkbaseService.test.ts +147 -4
- package/src/services/WorkbaseService.ts +214 -12
- package/src/workbase/schemas.test.ts +17 -6
- package/src/workbase/schemas.ts +16 -1
- package/src/workbase/workbase-choice.ts +2 -0
|
@@ -31,7 +31,7 @@ describe("workbase command", () => {
|
|
|
31
31
|
}),
|
|
32
32
|
),
|
|
33
33
|
)
|
|
34
|
-
const
|
|
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(
|
|
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 () => {
|
package/src/commands/workbase.ts
CHANGED
|
@@ -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
|
|
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(
|
|
29
|
-
: `Added workbase ${
|
|
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
|
|
41
|
+
const registrations = yield* service.listRegistrations(
|
|
42
|
+
options.configDirectory,
|
|
43
|
+
)
|
|
35
44
|
if (options.json) {
|
|
36
|
-
log(JSON.stringify(
|
|
45
|
+
log(JSON.stringify(registrations, null, 2))
|
|
37
46
|
} else {
|
|
38
|
-
for (const
|
|
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(
|
|
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>
|
|
54
|
-
list
|
|
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
|
-
--
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
475
|
+
return removed
|
|
303
476
|
}),
|
|
304
477
|
|
|
305
|
-
|
|
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 }) =>
|
|
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()) =>
|
|
@@ -185,23 +185,34 @@ describe("workbase registry", () => {
|
|
|
185
185
|
test("accepts registered paths", () => {
|
|
186
186
|
expect(
|
|
187
187
|
Schema.decodeUnknownSync(WorkbaseRegistry)({
|
|
188
|
-
version:
|
|
189
|
-
workbases: [
|
|
188
|
+
version: 2,
|
|
189
|
+
workbases: [
|
|
190
|
+
{ id: "wb-one", name: "one", path: "/work/one" },
|
|
191
|
+
{ id: "wb-two", path: "/work/two" },
|
|
192
|
+
],
|
|
193
|
+
defaultId: "wb-one",
|
|
190
194
|
}),
|
|
191
|
-
).toEqual({
|
|
195
|
+
).toEqual({
|
|
196
|
+
version: 2,
|
|
197
|
+
workbases: [
|
|
198
|
+
{ id: "wb-one", name: "one", path: "/work/one" },
|
|
199
|
+
{ id: "wb-two", path: "/work/two" },
|
|
200
|
+
],
|
|
201
|
+
defaultId: "wb-one",
|
|
202
|
+
})
|
|
192
203
|
})
|
|
193
204
|
|
|
194
205
|
test("rejects invalid versions and empty paths", () => {
|
|
195
206
|
expect(() =>
|
|
196
207
|
Schema.decodeUnknownSync(WorkbaseRegistry)({
|
|
197
|
-
version:
|
|
208
|
+
version: 3,
|
|
198
209
|
workbases: [],
|
|
199
210
|
}),
|
|
200
211
|
).toThrow()
|
|
201
212
|
expect(() =>
|
|
202
213
|
Schema.decodeUnknownSync(WorkbaseRegistry)({
|
|
203
|
-
version:
|
|
204
|
-
workbases: [""],
|
|
214
|
+
version: 2,
|
|
215
|
+
workbases: [{ id: "wb-one", path: "" }],
|
|
205
216
|
}),
|
|
206
217
|
).toThrow()
|
|
207
218
|
})
|