@jiggai/recipes 0.2.11
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 +142 -0
- package/clawcipes_cook.jpg +0 -0
- package/docs/AGENTS_AND_SKILLS.md +232 -0
- package/docs/BUNDLED_RECIPES.md +208 -0
- package/docs/CLAWCIPES_KITCHEN.md +27 -0
- package/docs/COMMANDS.md +266 -0
- package/docs/INSTALLATION.md +80 -0
- package/docs/RECIPE_FORMAT.md +127 -0
- package/docs/TEAM_WORKFLOW.md +62 -0
- package/docs/TUTORIAL_CREATE_RECIPE.md +151 -0
- package/docs/shared-context.md +47 -0
- package/docs/verify-built-in-team-recipes.md +65 -0
- package/index.ts +2244 -0
- package/openclaw.plugin.json +28 -0
- package/package.json +46 -0
- package/recipes/default/customer-support-team.md +246 -0
- package/recipes/default/developer.md +73 -0
- package/recipes/default/development-team.md +389 -0
- package/recipes/default/editor.md +74 -0
- package/recipes/default/product-team.md +298 -0
- package/recipes/default/project-manager.md +69 -0
- package/recipes/default/research-team.md +243 -0
- package/recipes/default/researcher.md +75 -0
- package/recipes/default/social-team.md +205 -0
- package/recipes/default/writing-team.md +228 -0
- package/src/lib/bindings.ts +59 -0
- package/src/lib/cleanup-workspaces.ts +173 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/lanes.ts +63 -0
- package/src/lib/recipe-frontmatter.ts +59 -0
- package/src/lib/remove-team.ts +200 -0
- package/src/lib/scaffold-templates.ts +7 -0
- package/src/lib/shared-context.ts +52 -0
- package/src/lib/ticket-finder.ts +60 -0
- package/src/lib/ticket-workflow.ts +153 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: social-team
|
|
3
|
+
name: Social Media Team
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
description: A small social media team with a shared workspace (lead, research, writer, editor).
|
|
6
|
+
kind: team
|
|
7
|
+
cronJobs:
|
|
8
|
+
- id: lead-triage-loop
|
|
9
|
+
name: "Lead triage loop"
|
|
10
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
11
|
+
timezone: "America/New_York"
|
|
12
|
+
message: "Automated lead triage loop: triage inbox/tickets, assign work, and update notes/status.md."
|
|
13
|
+
enabledByDefault: false
|
|
14
|
+
- id: execution-loop
|
|
15
|
+
name: "Execution loop"
|
|
16
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
17
|
+
timezone: "America/New_York"
|
|
18
|
+
message: "Automated execution loop: make progress on in-progress tickets, keep changes small/safe, and update notes/status.md."
|
|
19
|
+
enabledByDefault: false
|
|
20
|
+
# pr-watcher omitted (enable only when a real PR integration exists)
|
|
21
|
+
requiredSkills: []
|
|
22
|
+
team:
|
|
23
|
+
teamId: social-team
|
|
24
|
+
agents:
|
|
25
|
+
- role: lead
|
|
26
|
+
name: Social Team Lead
|
|
27
|
+
tools:
|
|
28
|
+
profile: "coding"
|
|
29
|
+
allow: ["group:fs", "group:web", "group:runtime"]
|
|
30
|
+
deny: ["exec"]
|
|
31
|
+
- role: research
|
|
32
|
+
name: Social Trend Researcher
|
|
33
|
+
- role: writer
|
|
34
|
+
name: Social Content Writer
|
|
35
|
+
- role: editor
|
|
36
|
+
name: Social Editor
|
|
37
|
+
|
|
38
|
+
# For team recipes, template keys are namespaced by role, e.g. lead.soul
|
|
39
|
+
templates:
|
|
40
|
+
lead.soul: |
|
|
41
|
+
# SOUL.md
|
|
42
|
+
|
|
43
|
+
You are the Team Lead / Dispatcher for {{teamId}}.
|
|
44
|
+
|
|
45
|
+
Your job:
|
|
46
|
+
- Read new requests in {{teamDir}}/inbox
|
|
47
|
+
- Break them into assignments for the specialist agents
|
|
48
|
+
- Keep a lightweight plan in {{teamDir}}/notes/plan.md
|
|
49
|
+
- Consolidate deliverables into {{teamDir}}/outbox
|
|
50
|
+
|
|
51
|
+
lead.agents: |
|
|
52
|
+
# AGENTS.md
|
|
53
|
+
|
|
54
|
+
## Shared team workspace
|
|
55
|
+
|
|
56
|
+
Team: {{teamId}}
|
|
57
|
+
Team directory: {{teamDir}}
|
|
58
|
+
|
|
59
|
+
Workflow (mapped to canonical lanes):
|
|
60
|
+
- backlog → in-progress → testing → done
|
|
61
|
+
- Intake: check `inbox/` and write tickets into work/backlog/
|
|
62
|
+
- Drafting: use work/in-progress/ for active drafting
|
|
63
|
+
- Approval/review: use work/testing/ for review + final checks
|
|
64
|
+
- Done: move to work/done/ and publish/schedule into outbox/
|
|
65
|
+
|
|
66
|
+
QA verification:
|
|
67
|
+
- Use notes/QA_CHECKLIST.md
|
|
68
|
+
- Preferred record: work/testing/<ticket>.testing-verified.md
|
|
69
|
+
|
|
70
|
+
research.soul: |
|
|
71
|
+
# SOUL.md
|
|
72
|
+
|
|
73
|
+
You are a Social Trend Researcher on {{teamId}}.
|
|
74
|
+
You produce concise, sourced research for the writer and lead.
|
|
75
|
+
|
|
76
|
+
research.agents: |
|
|
77
|
+
# AGENTS.md
|
|
78
|
+
|
|
79
|
+
Shared team directory: {{teamDir}}
|
|
80
|
+
|
|
81
|
+
Output conventions:
|
|
82
|
+
- Write findings to `work/research/` with clear filenames.
|
|
83
|
+
- Include links and bullet summaries.
|
|
84
|
+
|
|
85
|
+
writer.soul: |
|
|
86
|
+
# SOUL.md
|
|
87
|
+
|
|
88
|
+
You are a Social Content Writer on {{teamId}}.
|
|
89
|
+
Turn research + prompts into drafts with strong hooks.
|
|
90
|
+
|
|
91
|
+
writer.agents: |
|
|
92
|
+
# AGENTS.md
|
|
93
|
+
|
|
94
|
+
Shared team directory: {{teamDir}}
|
|
95
|
+
|
|
96
|
+
Output conventions:
|
|
97
|
+
- Drafts go in `work/drafts/`.
|
|
98
|
+
- Keep tone consistent with the request.
|
|
99
|
+
|
|
100
|
+
editor.soul: |
|
|
101
|
+
# SOUL.md
|
|
102
|
+
|
|
103
|
+
You are a Social Editor on {{teamId}}.
|
|
104
|
+
Polish drafts for clarity, structure, and punch.
|
|
105
|
+
|
|
106
|
+
editor.agents: |
|
|
107
|
+
# AGENTS.md
|
|
108
|
+
|
|
109
|
+
Shared team directory: {{teamDir}}
|
|
110
|
+
|
|
111
|
+
Output conventions:
|
|
112
|
+
- Edited drafts go in `work/edited/`.
|
|
113
|
+
- Provide a short changelog at the top.
|
|
114
|
+
|
|
115
|
+
## QA verification (approval)
|
|
116
|
+
Before moving a deliverable to done/scheduled:
|
|
117
|
+
- Record verification using notes/QA_CHECKLIST.md.
|
|
118
|
+
- Preferred: create work/testing/<ticket>.testing-verified.md.
|
|
119
|
+
|
|
120
|
+
lead.tools: |
|
|
121
|
+
# TOOLS.md
|
|
122
|
+
|
|
123
|
+
(empty)
|
|
124
|
+
|
|
125
|
+
lead.status: |
|
|
126
|
+
# STATUS.md
|
|
127
|
+
|
|
128
|
+
- (empty)
|
|
129
|
+
|
|
130
|
+
lead.notes: |
|
|
131
|
+
# NOTES.md
|
|
132
|
+
|
|
133
|
+
- (empty)
|
|
134
|
+
|
|
135
|
+
research.tools: |
|
|
136
|
+
# TOOLS.md
|
|
137
|
+
|
|
138
|
+
(empty)
|
|
139
|
+
|
|
140
|
+
research.status: |
|
|
141
|
+
# STATUS.md
|
|
142
|
+
|
|
143
|
+
- (empty)
|
|
144
|
+
|
|
145
|
+
research.notes: |
|
|
146
|
+
# NOTES.md
|
|
147
|
+
|
|
148
|
+
- (empty)
|
|
149
|
+
|
|
150
|
+
writer.tools: |
|
|
151
|
+
# TOOLS.md
|
|
152
|
+
|
|
153
|
+
(empty)
|
|
154
|
+
|
|
155
|
+
writer.status: |
|
|
156
|
+
# STATUS.md
|
|
157
|
+
|
|
158
|
+
- (empty)
|
|
159
|
+
|
|
160
|
+
writer.notes: |
|
|
161
|
+
# NOTES.md
|
|
162
|
+
|
|
163
|
+
- (empty)
|
|
164
|
+
|
|
165
|
+
editor.tools: |
|
|
166
|
+
# TOOLS.md
|
|
167
|
+
|
|
168
|
+
(empty)
|
|
169
|
+
|
|
170
|
+
editor.status: |
|
|
171
|
+
# STATUS.md
|
|
172
|
+
|
|
173
|
+
- (empty)
|
|
174
|
+
|
|
175
|
+
editor.notes: |
|
|
176
|
+
# NOTES.md
|
|
177
|
+
|
|
178
|
+
- (empty)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
files:
|
|
182
|
+
- path: SOUL.md
|
|
183
|
+
template: soul
|
|
184
|
+
mode: createOnly
|
|
185
|
+
- path: AGENTS.md
|
|
186
|
+
template: agents
|
|
187
|
+
mode: createOnly
|
|
188
|
+
- path: TOOLS.md
|
|
189
|
+
template: tools
|
|
190
|
+
mode: createOnly
|
|
191
|
+
- path: STATUS.md
|
|
192
|
+
template: status
|
|
193
|
+
mode: createOnly
|
|
194
|
+
- path: NOTES.md
|
|
195
|
+
template: notes
|
|
196
|
+
mode: createOnly
|
|
197
|
+
|
|
198
|
+
tools:
|
|
199
|
+
profile: "coding"
|
|
200
|
+
allow: ["group:fs", "group:web"]
|
|
201
|
+
deny: ["exec"]
|
|
202
|
+
---
|
|
203
|
+
# Social Team Recipe
|
|
204
|
+
|
|
205
|
+
Scaffolds a shared team workspace and four namespaced agents.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: writing-team
|
|
3
|
+
name: Writing Team
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
description: A writing pipeline (lead, outliner, writer, editor) that produces drafts and polished deliverables.
|
|
6
|
+
kind: team
|
|
7
|
+
cronJobs:
|
|
8
|
+
- id: lead-triage-loop
|
|
9
|
+
name: "Lead triage loop"
|
|
10
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
11
|
+
timezone: "America/New_York"
|
|
12
|
+
message: "Automated lead triage loop: triage inbox/tickets, assign work, and update notes/status.md."
|
|
13
|
+
enabledByDefault: false
|
|
14
|
+
- id: execution-loop
|
|
15
|
+
name: "Execution loop"
|
|
16
|
+
schedule: "*/30 7-23 * * 1-5"
|
|
17
|
+
timezone: "America/New_York"
|
|
18
|
+
message: "Automated execution loop: make progress on in-progress tickets, keep changes small/safe, and update notes/status.md."
|
|
19
|
+
enabledByDefault: false
|
|
20
|
+
# pr-watcher omitted (enable only when a real PR integration exists)
|
|
21
|
+
requiredSkills: []
|
|
22
|
+
team:
|
|
23
|
+
teamId: writing-team
|
|
24
|
+
agents:
|
|
25
|
+
- role: lead
|
|
26
|
+
name: Writing Lead
|
|
27
|
+
tools:
|
|
28
|
+
profile: "coding"
|
|
29
|
+
allow: ["group:fs", "group:web", "group:runtime"]
|
|
30
|
+
deny: ["exec"]
|
|
31
|
+
- role: outliner
|
|
32
|
+
name: Outliner
|
|
33
|
+
tools:
|
|
34
|
+
profile: "coding"
|
|
35
|
+
allow: ["group:fs", "group:web"]
|
|
36
|
+
deny: ["exec"]
|
|
37
|
+
- role: writer
|
|
38
|
+
name: Writer
|
|
39
|
+
tools:
|
|
40
|
+
profile: "coding"
|
|
41
|
+
allow: ["group:fs", "group:web"]
|
|
42
|
+
deny: ["exec"]
|
|
43
|
+
- role: editor
|
|
44
|
+
name: Editor
|
|
45
|
+
tools:
|
|
46
|
+
profile: "coding"
|
|
47
|
+
allow: ["group:fs", "group:web"]
|
|
48
|
+
deny: ["exec"]
|
|
49
|
+
|
|
50
|
+
templates:
|
|
51
|
+
lead.soul: |
|
|
52
|
+
# SOUL.md
|
|
53
|
+
|
|
54
|
+
You are the Writing Lead / Editor-in-Chief for {{teamId}}.
|
|
55
|
+
|
|
56
|
+
Core job:
|
|
57
|
+
- Turn requests into briefs + tickets.
|
|
58
|
+
- Ensure tone + audience are specified.
|
|
59
|
+
- Keep the pipeline moving and enforce quality.
|
|
60
|
+
|
|
61
|
+
lead.agents: |
|
|
62
|
+
# AGENTS.md
|
|
63
|
+
|
|
64
|
+
Team: {{teamId}}
|
|
65
|
+
Team directory: {{teamDir}}
|
|
66
|
+
|
|
67
|
+
## Shared workspace
|
|
68
|
+
- inbox/ — requests
|
|
69
|
+
- work/backlog/ — tickets (0001-...)
|
|
70
|
+
- work/in-progress/ — active tickets
|
|
71
|
+
- work/testing/ — review/edit/QA (verification before publishing)
|
|
72
|
+
- work/done/ — completed tickets + DONE notes
|
|
73
|
+
- work/briefs/ — writing briefs
|
|
74
|
+
- work/outlines/ — outlines
|
|
75
|
+
- work/drafts/ — drafts
|
|
76
|
+
- work/edited/ — edited drafts
|
|
77
|
+
- outbox/ — finalized deliverables
|
|
78
|
+
|
|
79
|
+
## Dispatch loop
|
|
80
|
+
1) Intake in inbox/
|
|
81
|
+
2) Brief in work/briefs/
|
|
82
|
+
3) Assign outline → draft
|
|
83
|
+
4) Move to work/testing/ for edit/review
|
|
84
|
+
5) After verification (see notes/QA_CHECKLIST.md), move to work/done/ and finalize to outbox/
|
|
85
|
+
|
|
86
|
+
outliner.soul: |
|
|
87
|
+
# SOUL.md
|
|
88
|
+
|
|
89
|
+
You are an Outliner on {{teamId}}.
|
|
90
|
+
|
|
91
|
+
You produce strong structure before drafting.
|
|
92
|
+
|
|
93
|
+
outliner.agents: |
|
|
94
|
+
# AGENTS.md
|
|
95
|
+
|
|
96
|
+
Team directory: {{teamDir}}
|
|
97
|
+
|
|
98
|
+
Output conventions:
|
|
99
|
+
- Write outlines in work/outlines/
|
|
100
|
+
- Include:
|
|
101
|
+
- target audience
|
|
102
|
+
- thesis
|
|
103
|
+
- sections (H2/H3)
|
|
104
|
+
- key points per section
|
|
105
|
+
|
|
106
|
+
writer.soul: |
|
|
107
|
+
# SOUL.md
|
|
108
|
+
|
|
109
|
+
You are a Writer on {{teamId}}.
|
|
110
|
+
|
|
111
|
+
You draft quickly and clearly, matching the requested tone.
|
|
112
|
+
|
|
113
|
+
writer.agents: |
|
|
114
|
+
# AGENTS.md
|
|
115
|
+
|
|
116
|
+
Team directory: {{teamDir}}
|
|
117
|
+
|
|
118
|
+
Output conventions:
|
|
119
|
+
- Drafts go in work/drafts/
|
|
120
|
+
- Put assumptions and open questions at the top.
|
|
121
|
+
|
|
122
|
+
editor.soul: |
|
|
123
|
+
# SOUL.md
|
|
124
|
+
|
|
125
|
+
You are an Editor on {{teamId}}.
|
|
126
|
+
|
|
127
|
+
You polish drafts for clarity, structure, and punch.
|
|
128
|
+
|
|
129
|
+
editor.agents: |
|
|
130
|
+
# AGENTS.md
|
|
131
|
+
|
|
132
|
+
Team directory: {{teamDir}}
|
|
133
|
+
|
|
134
|
+
Output conventions:
|
|
135
|
+
- Edited drafts go in work/edited/
|
|
136
|
+
- Provide a short changelog at the top.
|
|
137
|
+
- Flag any factual claims that need citations.
|
|
138
|
+
|
|
139
|
+
## QA verification
|
|
140
|
+
Before a deliverable is marked done/published:
|
|
141
|
+
- Record verification using notes/QA_CHECKLIST.md.
|
|
142
|
+
- Preferred: create work/testing/<ticket>.testing-verified.md.
|
|
143
|
+
|
|
144
|
+
lead.tools: |
|
|
145
|
+
# TOOLS.md
|
|
146
|
+
|
|
147
|
+
# Agent-local notes for lead (paths, conventions, env quirks).
|
|
148
|
+
|
|
149
|
+
lead.status: |
|
|
150
|
+
# STATUS.md
|
|
151
|
+
|
|
152
|
+
- (empty)
|
|
153
|
+
|
|
154
|
+
lead.notes: |
|
|
155
|
+
# NOTES.md
|
|
156
|
+
|
|
157
|
+
- (empty)
|
|
158
|
+
|
|
159
|
+
outliner.tools: |
|
|
160
|
+
# TOOLS.md
|
|
161
|
+
|
|
162
|
+
# Agent-local notes for outliner (paths, conventions, env quirks).
|
|
163
|
+
|
|
164
|
+
outliner.status: |
|
|
165
|
+
# STATUS.md
|
|
166
|
+
|
|
167
|
+
- (empty)
|
|
168
|
+
|
|
169
|
+
outliner.notes: |
|
|
170
|
+
# NOTES.md
|
|
171
|
+
|
|
172
|
+
- (empty)
|
|
173
|
+
|
|
174
|
+
writer.tools: |
|
|
175
|
+
# TOOLS.md
|
|
176
|
+
|
|
177
|
+
# Agent-local notes for writer (paths, conventions, env quirks).
|
|
178
|
+
|
|
179
|
+
writer.status: |
|
|
180
|
+
# STATUS.md
|
|
181
|
+
|
|
182
|
+
- (empty)
|
|
183
|
+
|
|
184
|
+
writer.notes: |
|
|
185
|
+
# NOTES.md
|
|
186
|
+
|
|
187
|
+
- (empty)
|
|
188
|
+
|
|
189
|
+
editor.tools: |
|
|
190
|
+
# TOOLS.md
|
|
191
|
+
|
|
192
|
+
# Agent-local notes for editor (paths, conventions, env quirks).
|
|
193
|
+
|
|
194
|
+
editor.status: |
|
|
195
|
+
# STATUS.md
|
|
196
|
+
|
|
197
|
+
- (empty)
|
|
198
|
+
|
|
199
|
+
editor.notes: |
|
|
200
|
+
# NOTES.md
|
|
201
|
+
|
|
202
|
+
- (empty)
|
|
203
|
+
|
|
204
|
+
files:
|
|
205
|
+
- path: SOUL.md
|
|
206
|
+
template: soul
|
|
207
|
+
mode: createOnly
|
|
208
|
+
- path: AGENTS.md
|
|
209
|
+
template: agents
|
|
210
|
+
mode: createOnly
|
|
211
|
+
- path: TOOLS.md
|
|
212
|
+
template: tools
|
|
213
|
+
mode: createOnly
|
|
214
|
+
- path: STATUS.md
|
|
215
|
+
template: status
|
|
216
|
+
mode: createOnly
|
|
217
|
+
- path: NOTES.md
|
|
218
|
+
template: notes
|
|
219
|
+
mode: createOnly
|
|
220
|
+
|
|
221
|
+
tools:
|
|
222
|
+
profile: "coding"
|
|
223
|
+
allow: ["group:fs", "group:web"]
|
|
224
|
+
deny: ["exec"]
|
|
225
|
+
---
|
|
226
|
+
# Writing Team Recipe
|
|
227
|
+
|
|
228
|
+
A lightweight writing pipeline that pairs briefs/outlines/drafts/edits with a file-first ticket workflow.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Minimal extracted binding helper so we can test precedence without running the CLI.
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export type BindingMatch = {
|
|
6
|
+
channel?: string;
|
|
7
|
+
peer?: string;
|
|
8
|
+
[k: string]: any;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type BindingSnippet = {
|
|
12
|
+
agentId: string;
|
|
13
|
+
match: BindingMatch;
|
|
14
|
+
to: any;
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function stableStringify(obj: any) {
|
|
19
|
+
const seen = new WeakSet();
|
|
20
|
+
const walk = (x: any): any => {
|
|
21
|
+
if (x && typeof x === 'object') {
|
|
22
|
+
if (seen.has(x)) return '[Circular]';
|
|
23
|
+
seen.add(x);
|
|
24
|
+
if (Array.isArray(x)) return x.map(walk);
|
|
25
|
+
const keys = Object.keys(x).sort();
|
|
26
|
+
const out: any = {};
|
|
27
|
+
for (const k of keys) out[k] = walk(x[k]);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
return x;
|
|
31
|
+
};
|
|
32
|
+
return JSON.stringify(walk(obj));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function upsertBindingInConfig(cfgObj: any, binding: BindingSnippet) {
|
|
36
|
+
if (!Array.isArray(cfgObj.bindings)) cfgObj.bindings = [];
|
|
37
|
+
const list: any[] = cfgObj.bindings;
|
|
38
|
+
|
|
39
|
+
const sigPayload = stableStringify({ agentId: binding.agentId, match: binding.match });
|
|
40
|
+
const sig = crypto.createHash('sha256').update(sigPayload).digest('hex');
|
|
41
|
+
|
|
42
|
+
const idx = list.findIndex((b: any) => {
|
|
43
|
+
const payload = stableStringify({ agentId: b.agentId, match: b.match });
|
|
44
|
+
const bsig = crypto.createHash('sha256').update(payload).digest('hex');
|
|
45
|
+
return bsig === sig;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (idx >= 0) {
|
|
49
|
+
// Update in place (preserve ordering)
|
|
50
|
+
list[idx] = { ...list[idx], ...binding };
|
|
51
|
+
return { changed: false as const, note: 'already-present' as const };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Most-specific-first: if a peer match is specified, insert at front so it wins.
|
|
55
|
+
if (binding.match?.peer) list.unshift(binding);
|
|
56
|
+
else list.push(binding);
|
|
57
|
+
|
|
58
|
+
return { changed: true as const, note: 'added' as const };
|
|
59
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type CleanupDecision =
|
|
5
|
+
| { kind: 'candidate'; teamId: string; dirName: string; absPath: string }
|
|
6
|
+
| { kind: 'skip'; teamId?: string; dirName: string; absPath: string; reason: string };
|
|
7
|
+
|
|
8
|
+
export type CleanupPlan = {
|
|
9
|
+
rootDir: string;
|
|
10
|
+
prefixes: string[];
|
|
11
|
+
protectedTeamIds: string[];
|
|
12
|
+
decisions: CleanupDecision[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_ALLOWED_PREFIXES = ['smoke-', 'qa-', 'tmp-', 'test-'] as const;
|
|
16
|
+
export const DEFAULT_PROTECTED_TEAM_IDS = ['development-team'] as const;
|
|
17
|
+
|
|
18
|
+
async function isDir(p: string) {
|
|
19
|
+
try {
|
|
20
|
+
const st = await fs.stat(p);
|
|
21
|
+
return st.isDirectory();
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Refuse to operate on symlinks (for safety). */
|
|
28
|
+
async function isSymlink(p: string) {
|
|
29
|
+
try {
|
|
30
|
+
const st = await fs.lstat(p);
|
|
31
|
+
return st.isSymbolicLink();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseTeamIdFromWorkspaceDirName(dirName: string) {
|
|
38
|
+
if (!dirName.startsWith('workspace-')) return null;
|
|
39
|
+
const teamId = dirName.slice('workspace-'.length);
|
|
40
|
+
return teamId || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isEligibleTeamId(opts: { teamId: string; prefixes: string[]; protectedTeamIds: string[] }) {
|
|
44
|
+
const { teamId, prefixes, protectedTeamIds } = opts;
|
|
45
|
+
|
|
46
|
+
if (!teamId.endsWith('-team')) return { ok: false, reason: 'teamId does not end with -team' } as const;
|
|
47
|
+
|
|
48
|
+
if (protectedTeamIds.includes(teamId.replace(/-team$/, ''))) {
|
|
49
|
+
// Back-compat: protect by base id too if someone passes development-team-team etc.
|
|
50
|
+
return { ok: false, reason: 'protected teamId' } as const;
|
|
51
|
+
}
|
|
52
|
+
if (protectedTeamIds.includes(teamId)) return { ok: false, reason: 'protected teamId' } as const;
|
|
53
|
+
|
|
54
|
+
const okPrefix = prefixes.some((p) => teamId.startsWith(p));
|
|
55
|
+
if (!okPrefix) return { ok: false, reason: `teamId does not start with an allowed prefix (${prefixes.join(', ')})` } as const;
|
|
56
|
+
|
|
57
|
+
return { ok: true } as const;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a safe cleanup plan for workspace-<teamId> directories under rootDir.
|
|
62
|
+
*
|
|
63
|
+
* Safety rails:
|
|
64
|
+
* - only directories named workspace-<teamId>
|
|
65
|
+
* - teamId must end with -team and start with an allowed prefix
|
|
66
|
+
* - protected teamIds are always skipped
|
|
67
|
+
* - refuses symlinks
|
|
68
|
+
* - resolved path must remain within rootDir
|
|
69
|
+
*/
|
|
70
|
+
export async function planWorkspaceCleanup(opts: {
|
|
71
|
+
rootDir: string;
|
|
72
|
+
prefixes?: string[];
|
|
73
|
+
protectedTeamIds?: string[];
|
|
74
|
+
}) {
|
|
75
|
+
const rootDir = path.resolve(opts.rootDir);
|
|
76
|
+
const prefixes = (opts.prefixes?.length ? opts.prefixes : [...DEFAULT_ALLOWED_PREFIXES]) as string[];
|
|
77
|
+
const protectedTeamIds = (opts.protectedTeamIds?.length ? opts.protectedTeamIds : [...DEFAULT_PROTECTED_TEAM_IDS]) as string[];
|
|
78
|
+
|
|
79
|
+
const decisions: CleanupDecision[] = [];
|
|
80
|
+
|
|
81
|
+
let entries: string[] = [];
|
|
82
|
+
try {
|
|
83
|
+
entries = await fs.readdir(rootDir);
|
|
84
|
+
} catch (e: any) {
|
|
85
|
+
return {
|
|
86
|
+
rootDir,
|
|
87
|
+
prefixes,
|
|
88
|
+
protectedTeamIds,
|
|
89
|
+
decisions: [
|
|
90
|
+
{
|
|
91
|
+
kind: 'skip',
|
|
92
|
+
dirName: rootDir,
|
|
93
|
+
absPath: rootDir,
|
|
94
|
+
reason: `failed to read rootDir: ${e?.message ? String(e.message) : String(e)}`,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
} satisfies CleanupPlan;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const dirName of entries) {
|
|
101
|
+
if (!dirName.startsWith('workspace-')) continue;
|
|
102
|
+
|
|
103
|
+
const absPath = path.join(rootDir, dirName);
|
|
104
|
+
|
|
105
|
+
if (!(await isDir(absPath))) continue;
|
|
106
|
+
|
|
107
|
+
const teamId = parseTeamIdFromWorkspaceDirName(dirName);
|
|
108
|
+
if (!teamId) {
|
|
109
|
+
decisions.push({ kind: 'skip', dirName, absPath, reason: 'could not parse teamId' });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (await isSymlink(absPath)) {
|
|
114
|
+
decisions.push({ kind: 'skip', teamId, dirName, absPath, reason: 'refusing to operate on symlink' });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const real = await fs.realpath(absPath);
|
|
119
|
+
const rootReal = await fs.realpath(rootDir);
|
|
120
|
+
if (!real.startsWith(rootReal + path.sep) && real !== rootReal) {
|
|
121
|
+
decisions.push({ kind: 'skip', teamId, dirName, absPath, reason: 'resolved path escapes rootDir' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const elig = isEligibleTeamId({ teamId, prefixes, protectedTeamIds });
|
|
126
|
+
if (!elig.ok) {
|
|
127
|
+
decisions.push({ kind: 'skip', teamId, dirName, absPath, reason: elig.reason });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
decisions.push({ kind: 'candidate', teamId, dirName, absPath });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { rootDir, prefixes, protectedTeamIds, decisions } satisfies CleanupPlan;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function executeWorkspaceCleanup(plan: CleanupPlan, opts: { yes: boolean }) {
|
|
138
|
+
const candidates = plan.decisions.filter((d): d is Extract<CleanupDecision, { kind: 'candidate' }> => d.kind === 'candidate');
|
|
139
|
+
const skipped = plan.decisions.filter((d): d is Extract<CleanupDecision, { kind: 'skip' }> => d.kind === 'skip');
|
|
140
|
+
|
|
141
|
+
if (!opts.yes) {
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
dryRun: true,
|
|
145
|
+
rootDir: plan.rootDir,
|
|
146
|
+
candidates,
|
|
147
|
+
skipped,
|
|
148
|
+
deleted: [] as string[],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const deleted: string[] = [];
|
|
153
|
+
const deleteErrors: Array<{ path: string; error: string }> = [];
|
|
154
|
+
|
|
155
|
+
for (const c of candidates) {
|
|
156
|
+
try {
|
|
157
|
+
await fs.rm(c.absPath, { recursive: true, force: true });
|
|
158
|
+
deleted.push(c.absPath);
|
|
159
|
+
} catch (e: any) {
|
|
160
|
+
deleteErrors.push({ path: c.absPath, error: e?.message ? String(e.message) : String(e) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
ok: deleteErrors.length === 0,
|
|
166
|
+
dryRun: false,
|
|
167
|
+
rootDir: plan.rootDir,
|
|
168
|
+
candidates,
|
|
169
|
+
skipped,
|
|
170
|
+
deleted,
|
|
171
|
+
deleteErrors,
|
|
172
|
+
};
|
|
173
|
+
}
|