@markjaquith/agency 2.25.0 → 2.26.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 +14 -2
- package/package.json +1 -1
- package/skills/agency/SKILL.md +10 -1
- package/src/cli-parser.test.ts +17 -0
- package/src/cli-parser.ts +57 -2
- package/src/commands/repo.test.ts +44 -0
- package/src/commands/repo.ts +107 -1
- package/src/commands/workbase.test.ts +47 -0
- package/src/commands/workbase.ts +52 -1
- package/src/services/RepositoryService.test.ts +129 -1
- package/src/services/RepositoryService.ts +181 -0
- package/src/services/WorkbaseService.test.ts +46 -0
- package/src/services/WorkbaseService.ts +94 -25
package/README.md
CHANGED
|
@@ -379,14 +379,24 @@ conditions remain visible in `warnings` or `unresolved` with a suggested action.
|
|
|
379
379
|
agency init [path] [--json]
|
|
380
380
|
agency workbase add <path> [--name <name>] [--json]
|
|
381
381
|
agency workbase list [--json]
|
|
382
|
+
agency workbase show <id|name|path> [--json]
|
|
383
|
+
agency workbase name <id|name|path> <name> [--json]
|
|
384
|
+
agency workbase name <id|name|path> --clear [--json]
|
|
382
385
|
agency workbase remove <id|name|path> [--json]
|
|
383
386
|
agency workbase prune [--json]
|
|
384
|
-
agency workbase default [<id|name> | --clear] [--json]
|
|
387
|
+
agency workbase default [<id|name|path> | --clear] [--json]
|
|
385
388
|
agency integration status [--json]
|
|
386
389
|
agency integration sync [--json]
|
|
387
390
|
agency repo add <alias> <remote> [--json]
|
|
388
391
|
agency repo link <alias> <path> [--json]
|
|
389
392
|
agency repo list [--json]
|
|
393
|
+
agency repo show <alias> [--json]
|
|
394
|
+
agency repo fetch <alias> [--json]
|
|
395
|
+
agency repo remove <alias> [--json]
|
|
396
|
+
agency repo unlink <alias> [--json]
|
|
397
|
+
agency repo rename <alias> <new-alias> [--json]
|
|
398
|
+
agency repo remote <alias> [remote] [--json]
|
|
399
|
+
agency repo verify <alias> [--json]
|
|
390
400
|
```
|
|
391
401
|
|
|
392
402
|
Registered workbases are stored in
|
|
@@ -395,7 +405,9 @@ Each registration has a stable ID and may have a unique name. A default workbase
|
|
|
395
405
|
is used when the current directory is outside every workbase. `prune` removes
|
|
396
406
|
registrations whose workbase configuration no longer exists.
|
|
397
407
|
`repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
|
|
398
|
-
repository. Alias names are then used by all documents and commands.
|
|
408
|
+
repository. Alias names are then used by all documents and commands. Remove,
|
|
409
|
+
unlink, and rename refuse aliases referenced by active work or backed by linked
|
|
410
|
+
worktrees, and report each blocker.
|
|
399
411
|
|
|
400
412
|
Commands that print Agency-owned results accept `--json`, including initialization,
|
|
401
413
|
integration inspection/sync, repository mutations, entity creation/list/show,
|
package/package.json
CHANGED
package/skills/agency/SKILL.md
CHANGED
|
@@ -65,7 +65,9 @@ Register and name known workbases so commands can select one from anywhere:
|
|
|
65
65
|
```bash
|
|
66
66
|
agency workbase add <path> [--name <name>]
|
|
67
67
|
agency workbase list
|
|
68
|
-
agency workbase
|
|
68
|
+
agency workbase show <id|name|path>
|
|
69
|
+
agency workbase name <id|name|path> <name> | --clear
|
|
70
|
+
agency workbase default [<id|name|path> | --clear]
|
|
69
71
|
agency workbase remove <id|name|path>
|
|
70
72
|
agency workbase prune
|
|
71
73
|
```
|
|
@@ -85,6 +87,13 @@ Link an existing local Git repository:
|
|
|
85
87
|
|
|
86
88
|
```bash
|
|
87
89
|
agency repo link <alias> <path>
|
|
90
|
+
agency repo show <alias>
|
|
91
|
+
agency repo fetch <alias>
|
|
92
|
+
agency repo remove <alias>
|
|
93
|
+
agency repo unlink <alias>
|
|
94
|
+
agency repo rename <alias> <new-alias>
|
|
95
|
+
agency repo remote <alias> [remote]
|
|
96
|
+
agency repo verify <alias>
|
|
88
97
|
```
|
|
89
98
|
|
|
90
99
|
Use aliases, never absolute paths or Git URLs, in epic/task/phase frontmatter.
|
package/src/cli-parser.test.ts
CHANGED
|
@@ -121,6 +121,23 @@ describe("strict CLI parsing", () => {
|
|
|
121
121
|
expect(parseCli(["status", "--no-pr"]).values["no-pr"]).toBe(true)
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
+
test("parses addressable resource maintenance commands", () => {
|
|
125
|
+
for (const args of [
|
|
126
|
+
["repo", "show", "agency", "--json"],
|
|
127
|
+
["repo", "fetch", "agency"],
|
|
128
|
+
["repo", "remove", "agency"],
|
|
129
|
+
["repo", "unlink", "agency"],
|
|
130
|
+
["repo", "rename", "agency", "renamed"],
|
|
131
|
+
["repo", "remote", "agency", "https://example.com/repo.git"],
|
|
132
|
+
["repo", "verify", "agency"],
|
|
133
|
+
["workbase", "show", "primary", "--json"],
|
|
134
|
+
["workbase", "name", "primary", "renamed"],
|
|
135
|
+
["workbase", "name", "primary", "--clear"],
|
|
136
|
+
]) {
|
|
137
|
+
expect(() => parseCli(args)).not.toThrow()
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
124
141
|
test("validates view filter values and conflicts", () => {
|
|
125
142
|
expect(() => parseCli(["epic", "list", "--status", "invalid"])).toThrow(
|
|
126
143
|
"Invalid '--status' value",
|
package/src/cli-parser.ts
CHANGED
|
@@ -127,7 +127,7 @@ const commands = {
|
|
|
127
127
|
},
|
|
128
128
|
},
|
|
129
129
|
workbase: {
|
|
130
|
-
usage: "agency workbase <add|list|remove|prune|default>",
|
|
130
|
+
usage: "agency workbase <add|list|show|name|remove|prune|default>",
|
|
131
131
|
options: {
|
|
132
132
|
...outputOptions,
|
|
133
133
|
name: { type: "string" },
|
|
@@ -146,6 +146,18 @@ const commands = {
|
|
|
146
146
|
maxArgs: 0,
|
|
147
147
|
options: ["json"],
|
|
148
148
|
},
|
|
149
|
+
show: {
|
|
150
|
+
usage: "agency workbase show <selector> [--json]",
|
|
151
|
+
minArgs: 1,
|
|
152
|
+
maxArgs: 1,
|
|
153
|
+
options: ["json"],
|
|
154
|
+
},
|
|
155
|
+
name: {
|
|
156
|
+
usage: "agency workbase name <selector> <name> | --clear [--json]",
|
|
157
|
+
minArgs: 1,
|
|
158
|
+
maxArgs: 2,
|
|
159
|
+
options: ["json", "clear"],
|
|
160
|
+
},
|
|
149
161
|
remove: {
|
|
150
162
|
usage: "agency workbase remove <selector> [--json]",
|
|
151
163
|
minArgs: 1,
|
|
@@ -186,7 +198,8 @@ const commands = {
|
|
|
186
198
|
},
|
|
187
199
|
},
|
|
188
200
|
repo: {
|
|
189
|
-
usage:
|
|
201
|
+
usage:
|
|
202
|
+
"agency repo <add|link|list|show|fetch|remove|unlink|rename|remote|verify>",
|
|
190
203
|
options: outputOptions,
|
|
191
204
|
subcommands: {
|
|
192
205
|
add: {
|
|
@@ -207,6 +220,48 @@ const commands = {
|
|
|
207
220
|
maxArgs: 0,
|
|
208
221
|
options: ["json"],
|
|
209
222
|
},
|
|
223
|
+
show: {
|
|
224
|
+
usage: "agency repo show <alias> [--json]",
|
|
225
|
+
minArgs: 1,
|
|
226
|
+
maxArgs: 1,
|
|
227
|
+
options: ["json"],
|
|
228
|
+
},
|
|
229
|
+
fetch: {
|
|
230
|
+
usage: "agency repo fetch <alias> [--json]",
|
|
231
|
+
minArgs: 1,
|
|
232
|
+
maxArgs: 1,
|
|
233
|
+
options: ["json"],
|
|
234
|
+
},
|
|
235
|
+
remove: {
|
|
236
|
+
usage: "agency repo remove <alias> [--json]",
|
|
237
|
+
minArgs: 1,
|
|
238
|
+
maxArgs: 1,
|
|
239
|
+
options: ["json"],
|
|
240
|
+
},
|
|
241
|
+
unlink: {
|
|
242
|
+
usage: "agency repo unlink <alias> [--json]",
|
|
243
|
+
minArgs: 1,
|
|
244
|
+
maxArgs: 1,
|
|
245
|
+
options: ["json"],
|
|
246
|
+
},
|
|
247
|
+
rename: {
|
|
248
|
+
usage: "agency repo rename <alias> <new-alias> [--json]",
|
|
249
|
+
minArgs: 2,
|
|
250
|
+
maxArgs: 2,
|
|
251
|
+
options: ["json"],
|
|
252
|
+
},
|
|
253
|
+
remote: {
|
|
254
|
+
usage: "agency repo remote <alias> [remote] [--json]",
|
|
255
|
+
minArgs: 1,
|
|
256
|
+
maxArgs: 2,
|
|
257
|
+
options: ["json"],
|
|
258
|
+
},
|
|
259
|
+
verify: {
|
|
260
|
+
usage: "agency repo verify <alias> [--json]",
|
|
261
|
+
minArgs: 1,
|
|
262
|
+
maxArgs: 1,
|
|
263
|
+
options: ["json"],
|
|
264
|
+
},
|
|
210
265
|
},
|
|
211
266
|
},
|
|
212
267
|
epic: {
|
|
@@ -52,6 +52,21 @@ describe("repo command", () => {
|
|
|
52
52
|
])
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
+
test("shows a repository by alias as JSON", async () => {
|
|
56
|
+
const logs = await captureLogs(() =>
|
|
57
|
+
runTestEffect(
|
|
58
|
+
repo({
|
|
59
|
+
subcommand: "show",
|
|
60
|
+
args: ["agency"],
|
|
61
|
+
cwd: root,
|
|
62
|
+
json: true,
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(JSON.parse(logs[0]!).alias).toBe("agency")
|
|
68
|
+
})
|
|
69
|
+
|
|
55
70
|
test("outputs a linked repository as JSON", async () => {
|
|
56
71
|
const target = join(root, "source")
|
|
57
72
|
await mkdir(target)
|
|
@@ -77,4 +92,33 @@ describe("repo command", () => {
|
|
|
77
92
|
path: join(root, "repos/linked"),
|
|
78
93
|
})
|
|
79
94
|
})
|
|
95
|
+
|
|
96
|
+
test("unlinks a linked repository by alias", async () => {
|
|
97
|
+
const target = join(root, "unlink-source")
|
|
98
|
+
await mkdir(target)
|
|
99
|
+
const git = Bun.spawn(["git", "init", target], {
|
|
100
|
+
stdout: "ignore",
|
|
101
|
+
stderr: "ignore",
|
|
102
|
+
})
|
|
103
|
+
expect(await git.exited).toBe(0)
|
|
104
|
+
await runTestEffect(
|
|
105
|
+
repo({
|
|
106
|
+
subcommand: "link",
|
|
107
|
+
args: ["linked", target],
|
|
108
|
+
cwd: root,
|
|
109
|
+
silent: true,
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
await runTestEffect(
|
|
114
|
+
repo({
|
|
115
|
+
subcommand: "unlink",
|
|
116
|
+
args: ["linked"],
|
|
117
|
+
cwd: root,
|
|
118
|
+
silent: true,
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
|
|
123
|
+
})
|
|
80
124
|
})
|
package/src/commands/repo.ts
CHANGED
|
@@ -9,6 +9,9 @@ interface RepoOptions extends BaseCommandOptions {
|
|
|
9
9
|
readonly json?: boolean
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
const requireArg = (args: readonly string[], index: number, usage: string) =>
|
|
13
|
+
args[index] ? Effect.succeed(args[index]) : Effect.fail(new Error(usage))
|
|
14
|
+
|
|
12
15
|
export const repo = (options: RepoOptions) =>
|
|
13
16
|
Effect.gen(function* () {
|
|
14
17
|
const repositories = yield* RepositoryService
|
|
@@ -61,10 +64,106 @@ export const repo = (options: RepoOptions) =>
|
|
|
61
64
|
return
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
case "show": {
|
|
68
|
+
const alias = yield* requireArg(
|
|
69
|
+
options.args,
|
|
70
|
+
0,
|
|
71
|
+
"Usage: agency repo show <alias>",
|
|
72
|
+
)
|
|
73
|
+
const item = yield* repositories.show(alias, cwd)
|
|
74
|
+
log(
|
|
75
|
+
options.json
|
|
76
|
+
? JSON.stringify(item, null, 2)
|
|
77
|
+
: `${item.alias}\t${item.kind}\t${item.target ?? item.remote ?? item.path}`,
|
|
78
|
+
)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case "fetch": {
|
|
83
|
+
const alias = yield* requireArg(
|
|
84
|
+
options.args,
|
|
85
|
+
0,
|
|
86
|
+
"Usage: agency repo fetch <alias>",
|
|
87
|
+
)
|
|
88
|
+
const item = yield* repositories.fetch(alias, cwd)
|
|
89
|
+
log(options.json ? JSON.stringify(item, null, 2) : `Fetched '${alias}'`)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case "remove":
|
|
94
|
+
case "unlink": {
|
|
95
|
+
const alias = yield* requireArg(
|
|
96
|
+
options.args,
|
|
97
|
+
0,
|
|
98
|
+
`Usage: agency repo ${options.subcommand} <alias>`,
|
|
99
|
+
)
|
|
100
|
+
const item = yield* repositories[options.subcommand](alias, cwd)
|
|
101
|
+
log(
|
|
102
|
+
options.json
|
|
103
|
+
? JSON.stringify(item, null, 2)
|
|
104
|
+
: `${options.subcommand === "unlink" ? "Unlinked" : "Removed"} '${alias}'`,
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case "rename": {
|
|
110
|
+
const alias = yield* requireArg(
|
|
111
|
+
options.args,
|
|
112
|
+
0,
|
|
113
|
+
"Usage: agency repo rename <alias> <new-alias>",
|
|
114
|
+
)
|
|
115
|
+
const newAlias = yield* requireArg(
|
|
116
|
+
options.args,
|
|
117
|
+
1,
|
|
118
|
+
"Usage: agency repo rename <alias> <new-alias>",
|
|
119
|
+
)
|
|
120
|
+
const item = yield* repositories.rename(alias, newAlias, cwd)
|
|
121
|
+
log(
|
|
122
|
+
options.json
|
|
123
|
+
? JSON.stringify(item, null, 2)
|
|
124
|
+
: `Renamed '${alias}' to '${newAlias}'`,
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "remote": {
|
|
130
|
+
const alias = yield* requireArg(
|
|
131
|
+
options.args,
|
|
132
|
+
0,
|
|
133
|
+
"Usage: agency repo remote <alias> [remote]",
|
|
134
|
+
)
|
|
135
|
+
const item = yield* repositories.remote(alias, options.args[1], cwd)
|
|
136
|
+
log(
|
|
137
|
+
options.json
|
|
138
|
+
? JSON.stringify(item, null, 2)
|
|
139
|
+
: (item.remote ?? "No origin remote configured"),
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case "verify": {
|
|
145
|
+
const alias = yield* requireArg(
|
|
146
|
+
options.args,
|
|
147
|
+
0,
|
|
148
|
+
"Usage: agency repo verify <alias>",
|
|
149
|
+
)
|
|
150
|
+
const report = yield* repositories.verify(alias, cwd)
|
|
151
|
+
if (options.json) log(JSON.stringify(report, null, 2))
|
|
152
|
+
if (!report.valid) {
|
|
153
|
+
return yield* Effect.fail(
|
|
154
|
+
new Error(
|
|
155
|
+
`Repository '${alias}' verification failed:\n${report.issues.map((issue) => `- ${issue}`).join("\n")}`,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
if (!options.json) log(`Verified repository '${alias}'`)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
64
163
|
default:
|
|
65
164
|
return yield* Effect.fail(
|
|
66
165
|
new Error(
|
|
67
|
-
"Subcommand is required. Available subcommands: add, link, list",
|
|
166
|
+
"Subcommand is required. Available subcommands: add, link, list, show, fetch, remove, unlink, rename, remote, verify",
|
|
68
167
|
),
|
|
69
168
|
)
|
|
70
169
|
}
|
|
@@ -77,6 +176,13 @@ Subcommands:
|
|
|
77
176
|
add <alias> <remote> Create a bare clone
|
|
78
177
|
link <alias> <path> Link an existing Git repository
|
|
79
178
|
list List repository aliases
|
|
179
|
+
show <alias> Show a repository alias
|
|
180
|
+
fetch <alias> Fetch and prune a repository
|
|
181
|
+
remove <alias> Remove an unused repository alias
|
|
182
|
+
unlink <alias> Remove an unused linked alias
|
|
183
|
+
rename <old> <new> Rename an unused repository alias
|
|
184
|
+
remote <alias> [url] Show or update the origin remote
|
|
185
|
+
verify <alias> Verify repository operation
|
|
80
186
|
|
|
81
187
|
Options:
|
|
82
188
|
--json Output repository aliases as JSON
|
|
@@ -108,6 +108,53 @@ describe("workbase command", () => {
|
|
|
108
108
|
).toEqual({ version: 2, workbases: [] })
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
test("names, shows, and clears a workbase name", async () => {
|
|
112
|
+
const added = await captureLogs(() =>
|
|
113
|
+
runTestEffect(
|
|
114
|
+
workbase({
|
|
115
|
+
subcommand: "add",
|
|
116
|
+
args: [root],
|
|
117
|
+
configDirectory,
|
|
118
|
+
json: true,
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
const registration = JSON.parse(added[0]!)
|
|
123
|
+
await runTestEffect(
|
|
124
|
+
workbase({
|
|
125
|
+
subcommand: "name",
|
|
126
|
+
args: [registration.id, "primary"],
|
|
127
|
+
configDirectory,
|
|
128
|
+
silent: true,
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const shown = await captureLogs(() =>
|
|
133
|
+
runTestEffect(
|
|
134
|
+
workbase({
|
|
135
|
+
subcommand: "show",
|
|
136
|
+
args: ["primary"],
|
|
137
|
+
configDirectory,
|
|
138
|
+
json: true,
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
expect(JSON.parse(shown[0]!).name).toBe("primary")
|
|
143
|
+
|
|
144
|
+
await runTestEffect(
|
|
145
|
+
workbase({
|
|
146
|
+
subcommand: "name",
|
|
147
|
+
args: [registration.id],
|
|
148
|
+
clear: true,
|
|
149
|
+
configDirectory,
|
|
150
|
+
silent: true,
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
expect(
|
|
154
|
+
await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
|
|
155
|
+
).toEqual({ version: 2, workbases: [registration] })
|
|
156
|
+
})
|
|
157
|
+
|
|
111
158
|
test("requires an add path", async () => {
|
|
112
159
|
await expect(
|
|
113
160
|
runTestEffect(
|
package/src/commands/workbase.ts
CHANGED
|
@@ -51,6 +51,54 @@ export const workbase = (options: WorkbaseOptions) =>
|
|
|
51
51
|
}
|
|
52
52
|
return
|
|
53
53
|
}
|
|
54
|
+
case "show": {
|
|
55
|
+
const selector = options.args[0]
|
|
56
|
+
if (!selector) {
|
|
57
|
+
return yield* Effect.fail(
|
|
58
|
+
new Error("Usage: agency workbase show <selector>"),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
const entry = yield* service.showRegistered(
|
|
62
|
+
selector,
|
|
63
|
+
options.configDirectory,
|
|
64
|
+
options.cwd,
|
|
65
|
+
)
|
|
66
|
+
log(
|
|
67
|
+
options.json
|
|
68
|
+
? JSON.stringify(entry, null, 2)
|
|
69
|
+
: `${entry.name ?? entry.id}\t${entry.path}`,
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
case "name": {
|
|
74
|
+
const selector = options.args[0]
|
|
75
|
+
const name = options.clear ? null : options.args[1]
|
|
76
|
+
if (
|
|
77
|
+
!selector ||
|
|
78
|
+
name === undefined ||
|
|
79
|
+
(options.clear && options.args[1] !== undefined)
|
|
80
|
+
) {
|
|
81
|
+
return yield* Effect.fail(
|
|
82
|
+
new Error(
|
|
83
|
+
"Usage: agency workbase name <selector> <name> | --clear",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
const entry = yield* service.nameRegistered(
|
|
88
|
+
selector,
|
|
89
|
+
name,
|
|
90
|
+
options.configDirectory,
|
|
91
|
+
options.cwd,
|
|
92
|
+
)
|
|
93
|
+
log(
|
|
94
|
+
options.json
|
|
95
|
+
? JSON.stringify(entry, null, 2)
|
|
96
|
+
: name === null
|
|
97
|
+
? `Cleared name for ${entry.id}`
|
|
98
|
+
: `Named workbase ${name}`,
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
54
102
|
case "remove": {
|
|
55
103
|
const entry = yield* service.removeRegistered(
|
|
56
104
|
options.args[0]!,
|
|
@@ -89,6 +137,7 @@ export const workbase = (options: WorkbaseOptions) =>
|
|
|
89
137
|
const entry = yield* service.setDefault(
|
|
90
138
|
selector,
|
|
91
139
|
options.configDirectory,
|
|
140
|
+
options.cwd,
|
|
92
141
|
)
|
|
93
142
|
log(
|
|
94
143
|
options.json
|
|
@@ -102,7 +151,7 @@ export const workbase = (options: WorkbaseOptions) =>
|
|
|
102
151
|
default:
|
|
103
152
|
return yield* Effect.fail(
|
|
104
153
|
new Error(
|
|
105
|
-
"Subcommand is required. Available: add, list, remove, prune, default",
|
|
154
|
+
"Subcommand is required. Available: add, list, show, name, remove, prune, default",
|
|
106
155
|
),
|
|
107
156
|
)
|
|
108
157
|
}
|
|
@@ -114,6 +163,8 @@ Usage: agency workbase <subcommand>
|
|
|
114
163
|
Subcommands:
|
|
115
164
|
add <path> Register an Agency workbase
|
|
116
165
|
list List registered workbases
|
|
166
|
+
show <selector> Show a registered workbase
|
|
167
|
+
name <selector> Set or clear a registered workbase name
|
|
117
168
|
remove <selector> Remove a registered workbase
|
|
118
169
|
prune Remove registrations whose paths no longer exist
|
|
119
170
|
default [selector] Show or set the default workbase
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { mkdir } from "node:fs/promises"
|
|
4
|
-
import { join } from "node:path"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
6
|
import { RepositoryService } from "./RepositoryService"
|
|
7
7
|
|
|
@@ -16,6 +16,12 @@ const runGit = async (args: string[]) => {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const write = async (root: string, path: string, content: string) => {
|
|
20
|
+
const fullPath = join(root, path)
|
|
21
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
22
|
+
await Bun.write(fullPath, content)
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
describe("RepositoryService", () => {
|
|
20
26
|
let root: string
|
|
21
27
|
|
|
@@ -103,4 +109,126 @@ describe("RepositoryService", () => {
|
|
|
103
109
|
),
|
|
104
110
|
).rejects.toThrow("already exists")
|
|
105
111
|
})
|
|
112
|
+
|
|
113
|
+
test("shows, fetches, updates, and verifies a repository", async () => {
|
|
114
|
+
const source = join(root, "source")
|
|
115
|
+
const replacement = join(root, "replacement.git")
|
|
116
|
+
await runGit(["init", "--initial-branch=main", source])
|
|
117
|
+
await Bun.write(join(source, "README.md"), "# Source\n")
|
|
118
|
+
await runGit(["-C", source, "add", "README.md"])
|
|
119
|
+
await runGit([
|
|
120
|
+
"-C",
|
|
121
|
+
source,
|
|
122
|
+
"-c",
|
|
123
|
+
"user.name=Agency Tests",
|
|
124
|
+
"-c",
|
|
125
|
+
"user.email=agency@example.com",
|
|
126
|
+
"commit",
|
|
127
|
+
"-m",
|
|
128
|
+
"Initial commit",
|
|
129
|
+
])
|
|
130
|
+
await runGit(["init", "--bare", "--initial-branch=main", replacement])
|
|
131
|
+
await runTestEffect(
|
|
132
|
+
RepositoryService.pipe(
|
|
133
|
+
Effect.flatMap((service) => service.add("agency", source, root)),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const result = await runTestEffect(
|
|
138
|
+
RepositoryService.pipe(
|
|
139
|
+
Effect.flatMap((service) =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
const shown = yield* service.show("agency", root)
|
|
142
|
+
yield* service.fetch("agency", root)
|
|
143
|
+
const updated = yield* service.remote("agency", replacement, root)
|
|
144
|
+
const verified = yield* service.verify("agency", root)
|
|
145
|
+
return { shown, updated, verified }
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
expect(result.shown.remote).toBe(source)
|
|
152
|
+
expect(result.updated.remote).toBe(replacement)
|
|
153
|
+
expect(result.verified.valid).toBe(true)
|
|
154
|
+
expect(result.verified.issues).toEqual([])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("renames and removes an unused repository", async () => {
|
|
158
|
+
const source = join(root, "source.git")
|
|
159
|
+
await runGit(["init", "--bare", "--initial-branch=main", source])
|
|
160
|
+
await runTestEffect(
|
|
161
|
+
RepositoryService.pipe(
|
|
162
|
+
Effect.flatMap((service) => service.add("old", source, root)),
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const removed = await runTestEffect(
|
|
167
|
+
RepositoryService.pipe(
|
|
168
|
+
Effect.flatMap((service) =>
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const renamed = yield* service.rename("old", "new", root)
|
|
171
|
+
expect(renamed.alias).toBe("new")
|
|
172
|
+
return yield* service.remove("new", root)
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
expect(removed.alias).toBe("new")
|
|
179
|
+
expect(await Bun.file(join(root, "repos/new/HEAD")).exists()).toBe(false)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("unlinks a symlink without deleting its target", async () => {
|
|
183
|
+
const target = join(root, "linked-repository")
|
|
184
|
+
await mkdir(target, { recursive: true })
|
|
185
|
+
await runGit(["init", "--initial-branch=main", target])
|
|
186
|
+
await runTestEffect(
|
|
187
|
+
RepositoryService.pipe(
|
|
188
|
+
Effect.flatMap((service) => service.link("linked", target, root)),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
await runTestEffect(
|
|
193
|
+
RepositoryService.pipe(
|
|
194
|
+
Effect.flatMap((service) => service.unlink("linked", root)),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
|
|
199
|
+
expect(await Bun.file(join(root, "repos/linked/.git/HEAD")).exists()).toBe(
|
|
200
|
+
false,
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test("reports active references and refuses unsafe removal", async () => {
|
|
205
|
+
const source = join(root, "source.git")
|
|
206
|
+
await runGit(["init", "--bare", "--initial-branch=main", source])
|
|
207
|
+
await runTestEffect(
|
|
208
|
+
RepositoryService.pipe(
|
|
209
|
+
Effect.flatMap((service) => service.add("agency", source, root)),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
await write(
|
|
213
|
+
root,
|
|
214
|
+
"tasks/active/TASK.md",
|
|
215
|
+
`---
|
|
216
|
+
ticketUrl: null
|
|
217
|
+
repo: agency
|
|
218
|
+
branch: task/active
|
|
219
|
+
base: main
|
|
220
|
+
pr: null
|
|
221
|
+
---
|
|
222
|
+
`,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
await expect(
|
|
226
|
+
runTestEffect(
|
|
227
|
+
RepositoryService.pipe(
|
|
228
|
+
Effect.flatMap((service) => service.remove("agency", root)),
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
).rejects.toThrow("active reference execution-unit:task/active")
|
|
232
|
+
expect(await Bun.file(join(root, "repos/agency/HEAD")).exists()).toBe(true)
|
|
233
|
+
})
|
|
106
234
|
})
|
|
@@ -2,6 +2,7 @@ import { Schema, TreeFormatter } from "@effect/schema"
|
|
|
2
2
|
import { Data, Effect, Either } from "effect"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
4
|
import { FileSystemService } from "./FileSystemService"
|
|
5
|
+
import { GraphService } from "./GraphService"
|
|
5
6
|
import { WorkbaseService } from "./WorkbaseService"
|
|
6
7
|
import { RepositoryAlias } from "../workbase/schemas"
|
|
7
8
|
|
|
@@ -18,6 +19,11 @@ interface RepositoryInfo {
|
|
|
18
19
|
readonly target: string | null
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
interface RepositoryVerification extends RepositoryInfo {
|
|
23
|
+
readonly valid: boolean
|
|
24
|
+
readonly issues: readonly string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
const validateAlias = (alias: string) => {
|
|
22
28
|
const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
|
|
23
29
|
return Either.isLeft(result)
|
|
@@ -29,6 +35,65 @@ const validateAlias = (alias: string) => {
|
|
|
29
35
|
: Effect.succeed(result.right)
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
const find = (alias: string, startPath: string) =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const service = yield* RepositoryService
|
|
41
|
+
const validAlias = yield* validateAlias(alias)
|
|
42
|
+
const repositories = yield* service.list(startPath)
|
|
43
|
+
const repository = repositories.find((item) => item.alias === validAlias)
|
|
44
|
+
if (!repository) {
|
|
45
|
+
return yield* new RepositoryError({
|
|
46
|
+
message: `Unknown repository alias '${validAlias}'`,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
return repository
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const fs = yield* FileSystemService
|
|
55
|
+
const graph = yield* GraphService
|
|
56
|
+
const report = yield* graph.get({ cwd: startPath })
|
|
57
|
+
const repositoryId = `repository:${repository.alias}`
|
|
58
|
+
const references = report.edges
|
|
59
|
+
.filter(
|
|
60
|
+
(edge) =>
|
|
61
|
+
edge.to === repositoryId &&
|
|
62
|
+
(edge.kind === "writes" || edge.kind === "references"),
|
|
63
|
+
)
|
|
64
|
+
.map((edge) => edge.from)
|
|
65
|
+
.sort()
|
|
66
|
+
const worktrees: string[] = []
|
|
67
|
+
if (repository.kind !== "symlink") {
|
|
68
|
+
const result = yield* fs.runCommand(
|
|
69
|
+
["git", "-C", repository.path, "worktree", "list", "--porcelain"],
|
|
70
|
+
{ captureOutput: true },
|
|
71
|
+
)
|
|
72
|
+
if (result.exitCode === 0) {
|
|
73
|
+
for (const block of result.stdout.trim().split(/\n\n+/)) {
|
|
74
|
+
if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
|
|
75
|
+
const path = block.match(/^worktree (.+)$/m)?.[1]
|
|
76
|
+
if (path) worktrees.push(path)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { references, worktrees }
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const blockers = yield* removalBlockers(repository, startPath)
|
|
86
|
+
const details = [
|
|
87
|
+
...blockers.references.map((item) => `active reference ${item}`),
|
|
88
|
+
...blockers.worktrees.map((item) => `linked worktree ${item}`),
|
|
89
|
+
]
|
|
90
|
+
if (details.length > 0) {
|
|
91
|
+
return yield* new RepositoryError({
|
|
92
|
+
message: `Repository alias '${repository.alias}' is in use and cannot be removed or renamed:\n${details.map((item) => `- ${item}`).join("\n")}`,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
32
97
|
export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
33
98
|
"RepositoryService",
|
|
34
99
|
{
|
|
@@ -154,6 +219,122 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
154
219
|
|
|
155
220
|
return repositories
|
|
156
221
|
}),
|
|
222
|
+
|
|
223
|
+
show: (alias: string, startPath: string = process.cwd()) =>
|
|
224
|
+
find(alias, startPath),
|
|
225
|
+
|
|
226
|
+
fetch: (alias: string, startPath: string = process.cwd()) =>
|
|
227
|
+
Effect.gen(function* () {
|
|
228
|
+
const fs = yield* FileSystemService
|
|
229
|
+
const repository = yield* find(alias, startPath)
|
|
230
|
+
const result = yield* fs.runCommand(
|
|
231
|
+
["git", "-C", repository.path, "fetch", "--prune"],
|
|
232
|
+
{ captureOutput: true },
|
|
233
|
+
)
|
|
234
|
+
if (result.exitCode !== 0) {
|
|
235
|
+
return yield* new RepositoryError({
|
|
236
|
+
message: `Failed to fetch repository '${alias}': ${result.stderr.trim()}`,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
return repository
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
remove: (alias: string, startPath: string = process.cwd()) =>
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
const fs = yield* FileSystemService
|
|
245
|
+
const repository = yield* find(alias, startPath)
|
|
246
|
+
yield* assertRemovable(repository, startPath)
|
|
247
|
+
yield* fs.deleteDirectory(repository.path)
|
|
248
|
+
return repository
|
|
249
|
+
}),
|
|
250
|
+
|
|
251
|
+
unlink: (alias: string, startPath: string = process.cwd()) =>
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
const service = yield* RepositoryService
|
|
254
|
+
const repository = yield* find(alias, startPath)
|
|
255
|
+
if (repository.kind !== "symlink") {
|
|
256
|
+
return yield* new RepositoryError({
|
|
257
|
+
message: `Repository alias '${alias}' is not a link; use 'agency repo remove ${alias}'`,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
return yield* service.remove(alias, startPath)
|
|
261
|
+
}),
|
|
262
|
+
|
|
263
|
+
rename: (
|
|
264
|
+
alias: string,
|
|
265
|
+
newAlias: string,
|
|
266
|
+
startPath: string = process.cwd(),
|
|
267
|
+
) =>
|
|
268
|
+
Effect.gen(function* () {
|
|
269
|
+
const fs = yield* FileSystemService
|
|
270
|
+
const workbase = yield* WorkbaseService
|
|
271
|
+
const repository = yield* find(alias, startPath)
|
|
272
|
+
const validNewAlias = yield* validateAlias(newAlias)
|
|
273
|
+
const root = yield* workbase.discover(startPath)
|
|
274
|
+
const destination = join(root, "repos", validNewAlias)
|
|
275
|
+
if (yield* fs.exists(destination)) {
|
|
276
|
+
return yield* new RepositoryError({
|
|
277
|
+
message: `Repository alias '${validNewAlias}' already exists`,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
yield* assertRemovable(repository, startPath)
|
|
281
|
+
yield* fs.moveDirectory(repository.path, destination)
|
|
282
|
+
return yield* find(validNewAlias, startPath)
|
|
283
|
+
}),
|
|
284
|
+
|
|
285
|
+
remote: (
|
|
286
|
+
alias: string,
|
|
287
|
+
remote: string | undefined,
|
|
288
|
+
startPath: string = process.cwd(),
|
|
289
|
+
) =>
|
|
290
|
+
Effect.gen(function* () {
|
|
291
|
+
const fs = yield* FileSystemService
|
|
292
|
+
const repository = yield* find(alias, startPath)
|
|
293
|
+
if (remote === undefined) return repository
|
|
294
|
+
if (!remote.trim()) {
|
|
295
|
+
return yield* new RepositoryError({
|
|
296
|
+
message: "Repository remote is required",
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
const hasOrigin = repository.remote !== null
|
|
300
|
+
const result = yield* fs.runCommand(
|
|
301
|
+
[
|
|
302
|
+
"git",
|
|
303
|
+
"-C",
|
|
304
|
+
repository.path,
|
|
305
|
+
"remote",
|
|
306
|
+
hasOrigin ? "set-url" : "add",
|
|
307
|
+
"origin",
|
|
308
|
+
remote,
|
|
309
|
+
],
|
|
310
|
+
{ captureOutput: true },
|
|
311
|
+
)
|
|
312
|
+
if (result.exitCode !== 0) {
|
|
313
|
+
return yield* new RepositoryError({
|
|
314
|
+
message: `Failed to update remote for repository '${alias}': ${result.stderr.trim()}`,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
return yield* find(alias, startPath)
|
|
318
|
+
}),
|
|
319
|
+
|
|
320
|
+
verify: (alias: string, startPath: string = process.cwd()) =>
|
|
321
|
+
Effect.gen(function* () {
|
|
322
|
+
const fs = yield* FileSystemService
|
|
323
|
+
const repository = yield* find(alias, startPath)
|
|
324
|
+
const issues: string[] = []
|
|
325
|
+
const git = yield* fs.runCommand(
|
|
326
|
+
["git", "-C", repository.path, "rev-parse", "--git-dir"],
|
|
327
|
+
{ captureOutput: true },
|
|
328
|
+
)
|
|
329
|
+
if (git.exitCode !== 0) issues.push("Path is not a Git repository")
|
|
330
|
+
if (repository.remote === null)
|
|
331
|
+
issues.push("Origin remote is not configured")
|
|
332
|
+
return {
|
|
333
|
+
...repository,
|
|
334
|
+
valid: issues.length === 0,
|
|
335
|
+
issues,
|
|
336
|
+
} satisfies RepositoryVerification
|
|
337
|
+
}),
|
|
157
338
|
}),
|
|
158
339
|
},
|
|
159
340
|
) {}
|
|
@@ -111,6 +111,52 @@ describe("WorkbaseService", () => {
|
|
|
111
111
|
expect(result.defaultWorkbase).toEqual(registered)
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
+
test("shows, names, clears names, and defaults by path", async () => {
|
|
115
|
+
const workbaseRoot = join(root, "workbase")
|
|
116
|
+
const configDirectory = join(root, "config")
|
|
117
|
+
await write(workbaseRoot, "agency.json", '{"version":2}\n')
|
|
118
|
+
const registered = await runTestEffect(
|
|
119
|
+
WorkbaseService.pipe(
|
|
120
|
+
Effect.flatMap((service) =>
|
|
121
|
+
service.register(workbaseRoot, configDirectory),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const result = await runTestEffect(
|
|
127
|
+
WorkbaseService.pipe(
|
|
128
|
+
Effect.flatMap((service) =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const named = yield* service.nameRegistered(
|
|
131
|
+
workbaseRoot,
|
|
132
|
+
"primary",
|
|
133
|
+
configDirectory,
|
|
134
|
+
)
|
|
135
|
+
const shown = yield* service.showRegistered(
|
|
136
|
+
"primary",
|
|
137
|
+
configDirectory,
|
|
138
|
+
)
|
|
139
|
+
const defaultWorkbase = yield* service.setDefault(
|
|
140
|
+
workbaseRoot,
|
|
141
|
+
configDirectory,
|
|
142
|
+
)
|
|
143
|
+
const unnamed = yield* service.nameRegistered(
|
|
144
|
+
registered.id,
|
|
145
|
+
null,
|
|
146
|
+
configDirectory,
|
|
147
|
+
)
|
|
148
|
+
return { named, shown, defaultWorkbase, unnamed }
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
expect(result.named.name).toBe("primary")
|
|
155
|
+
expect(result.shown).toEqual(result.named)
|
|
156
|
+
expect(result.defaultWorkbase?.id).toBe(registered.id)
|
|
157
|
+
expect(result.unnamed).toEqual({ id: registered.id, path: registered.path })
|
|
158
|
+
})
|
|
159
|
+
|
|
114
160
|
test("rejects names that collide with stable IDs", async () => {
|
|
115
161
|
const firstRoot = join(root, "first")
|
|
116
162
|
const secondRoot = join(root, "second")
|
|
@@ -146,6 +146,31 @@ const writeRegistry = (
|
|
|
146
146
|
yield* fs.writeJSON(path, registry)
|
|
147
147
|
})
|
|
148
148
|
|
|
149
|
+
const findRegistration = (
|
|
150
|
+
selector: string,
|
|
151
|
+
configDirectory?: string,
|
|
152
|
+
basePath: string = process.cwd(),
|
|
153
|
+
) =>
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
const fs = yield* FileSystemService
|
|
156
|
+
const { path, registry } = yield* readRegistry(configDirectory)
|
|
157
|
+
const candidatePath = resolve(basePath, selector)
|
|
158
|
+
const canonicalCandidate = (yield* fs.exists(candidatePath))
|
|
159
|
+
? yield* fs.realPath(candidatePath)
|
|
160
|
+
: candidatePath
|
|
161
|
+
const entry =
|
|
162
|
+
registry.workbases.find((item) => item.id === selector) ??
|
|
163
|
+
registry.workbases.find((item) => item.name === selector) ??
|
|
164
|
+
registry.workbases.find((item) => item.path === canonicalCandidate)
|
|
165
|
+
if (!entry) {
|
|
166
|
+
return yield* new WorkbaseRegistryError({
|
|
167
|
+
path,
|
|
168
|
+
message: `Unknown workbase selector '${selector}'`,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
return { path, registry, entry }
|
|
172
|
+
})
|
|
173
|
+
|
|
149
174
|
export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
150
175
|
"WorkbaseService",
|
|
151
176
|
{
|
|
@@ -401,22 +426,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
401
426
|
basePath: string = process.cwd(),
|
|
402
427
|
) =>
|
|
403
428
|
Effect.gen(function* () {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
: candidatePath
|
|
410
|
-
const entry =
|
|
411
|
-
registry.workbases.find((item) => item.id === selector) ??
|
|
412
|
-
registry.workbases.find((item) => item.name === selector) ??
|
|
413
|
-
registry.workbases.find((item) => item.path === canonicalCandidate)
|
|
414
|
-
if (!entry) {
|
|
415
|
-
return yield* new WorkbaseRegistryError({
|
|
416
|
-
path,
|
|
417
|
-
message: `Unknown workbase selector '${selector}'`,
|
|
418
|
-
})
|
|
419
|
-
}
|
|
429
|
+
const { path, registry, entry } = yield* findRegistration(
|
|
430
|
+
selector,
|
|
431
|
+
configDirectory,
|
|
432
|
+
basePath,
|
|
433
|
+
)
|
|
420
434
|
const workbases = registry.workbases.filter(
|
|
421
435
|
(item) => item.id !== entry.id,
|
|
422
436
|
)
|
|
@@ -431,6 +445,61 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
431
445
|
return entry
|
|
432
446
|
}),
|
|
433
447
|
|
|
448
|
+
showRegistered: (
|
|
449
|
+
selector: string,
|
|
450
|
+
configDirectory?: string,
|
|
451
|
+
basePath: string = process.cwd(),
|
|
452
|
+
) =>
|
|
453
|
+
findRegistration(selector, configDirectory, basePath).pipe(
|
|
454
|
+
Effect.map(({ entry }) => entry),
|
|
455
|
+
),
|
|
456
|
+
|
|
457
|
+
nameRegistered: (
|
|
458
|
+
selector: string,
|
|
459
|
+
name: string | null,
|
|
460
|
+
configDirectory?: string,
|
|
461
|
+
basePath: string = process.cwd(),
|
|
462
|
+
) =>
|
|
463
|
+
Effect.gen(function* () {
|
|
464
|
+
const { path, registry, entry } = yield* findRegistration(
|
|
465
|
+
selector,
|
|
466
|
+
configDirectory,
|
|
467
|
+
basePath,
|
|
468
|
+
)
|
|
469
|
+
if (name !== null) {
|
|
470
|
+
const decodedName = decode(EntityId, name)
|
|
471
|
+
if (!decodedName.success) {
|
|
472
|
+
return yield* new WorkbaseRegistryError({
|
|
473
|
+
path,
|
|
474
|
+
message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`,
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
const collision = registry.workbases.find(
|
|
478
|
+
(item) =>
|
|
479
|
+
item.id !== entry.id &&
|
|
480
|
+
(item.id === name || item.name === name),
|
|
481
|
+
)
|
|
482
|
+
if (collision) {
|
|
483
|
+
return yield* new WorkbaseRegistryError({
|
|
484
|
+
path,
|
|
485
|
+
message: `Workbase name '${name}' is already registered for ${collision.path}`,
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const updated = {
|
|
490
|
+
id: entry.id,
|
|
491
|
+
...(name === null ? {} : { name }),
|
|
492
|
+
path: entry.path,
|
|
493
|
+
}
|
|
494
|
+
yield* writeRegistry(path, {
|
|
495
|
+
...registry,
|
|
496
|
+
workbases: registry.workbases.map((item) =>
|
|
497
|
+
item.id === entry.id ? updated : item,
|
|
498
|
+
),
|
|
499
|
+
})
|
|
500
|
+
return updated
|
|
501
|
+
}),
|
|
502
|
+
|
|
434
503
|
pruneRegistered: (configDirectory?: string) =>
|
|
435
504
|
Effect.gen(function* () {
|
|
436
505
|
const fs = yield* FileSystemService
|
|
@@ -455,7 +524,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
455
524
|
return removed
|
|
456
525
|
}),
|
|
457
526
|
|
|
458
|
-
setDefault: (
|
|
527
|
+
setDefault: (
|
|
528
|
+
selector: string | null,
|
|
529
|
+
configDirectory?: string,
|
|
530
|
+
basePath: string = process.cwd(),
|
|
531
|
+
) =>
|
|
459
532
|
Effect.gen(function* () {
|
|
460
533
|
const { path, registry } = yield* readRegistry(configDirectory)
|
|
461
534
|
if (selector === null) {
|
|
@@ -465,15 +538,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
465
538
|
})
|
|
466
539
|
return null
|
|
467
540
|
}
|
|
468
|
-
const entry =
|
|
469
|
-
|
|
541
|
+
const { entry } = yield* findRegistration(
|
|
542
|
+
selector,
|
|
543
|
+
configDirectory,
|
|
544
|
+
basePath,
|
|
470
545
|
)
|
|
471
|
-
if (!entry) {
|
|
472
|
-
return yield* new WorkbaseRegistryError({
|
|
473
|
-
path,
|
|
474
|
-
message: `Unknown registered workbase selector '${selector}'`,
|
|
475
|
-
})
|
|
476
|
-
}
|
|
477
546
|
yield* writeRegistry(path, {
|
|
478
547
|
version: 2,
|
|
479
548
|
workbases: registry.workbases,
|