forge-workflow 0.0.5 → 0.0.6
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/.claude/commands/dev.md +6 -1
- package/.claude/commands/plan.md +59 -14
- package/.claude/commands/premerge.md +10 -0
- package/.claude/commands/review.md +7 -1
- package/.claude/commands/ship.md +95 -47
- package/.claude/commands/status.md +42 -0
- package/.claude/commands/validate.md +7 -1
- package/.claude/commands/verify.md +52 -4
- package/.claude/rules/workflow.md +16 -0
- package/.claude/scripts/greptile-resolve.sh +32 -0
- package/.cline/workflows/dev.md +6 -1
- package/.cline/workflows/plan.md +59 -14
- package/.cline/workflows/premerge.md +10 -0
- package/.cline/workflows/review.md +7 -1
- package/.cline/workflows/ship.md +95 -47
- package/.cline/workflows/status.md +42 -0
- package/.cline/workflows/validate.md +7 -1
- package/.cline/workflows/verify.md +52 -4
- package/.codex/skills/dev/SKILL.md +6 -1
- package/.codex/skills/plan/SKILL.md +59 -14
- package/.codex/skills/premerge/SKILL.md +10 -0
- package/.codex/skills/review/SKILL.md +7 -1
- package/.codex/skills/ship/SKILL.md +95 -47
- package/.codex/skills/status/SKILL.md +42 -0
- package/.codex/skills/validate/SKILL.md +7 -1
- package/.codex/skills/verify/SKILL.md +52 -4
- package/.cursor/commands/dev.md +6 -1
- package/.cursor/commands/plan.md +59 -14
- package/.cursor/commands/premerge.md +10 -0
- package/.cursor/commands/review.md +7 -1
- package/.cursor/commands/ship.md +95 -47
- package/.cursor/commands/status.md +42 -0
- package/.cursor/commands/validate.md +7 -1
- package/.cursor/commands/verify.md +52 -4
- package/.github/prompts/dev.prompt.md +6 -1
- package/.github/prompts/plan.prompt.md +59 -14
- package/.github/prompts/premerge.prompt.md +10 -0
- package/.github/prompts/review.prompt.md +7 -1
- package/.github/prompts/ship.prompt.md +95 -47
- package/.github/prompts/status.prompt.md +42 -0
- package/.github/prompts/validate.prompt.md +7 -1
- package/.github/prompts/verify.prompt.md +52 -4
- package/.kilocode/workflows/dev.md +6 -1
- package/.kilocode/workflows/plan.md +59 -14
- package/.kilocode/workflows/premerge.md +10 -0
- package/.kilocode/workflows/review.md +7 -1
- package/.kilocode/workflows/ship.md +95 -47
- package/.kilocode/workflows/status.md +42 -0
- package/.kilocode/workflows/validate.md +7 -1
- package/.kilocode/workflows/verify.md +52 -4
- package/.opencode/commands/dev.md +6 -1
- package/.opencode/commands/plan.md +59 -14
- package/.opencode/commands/premerge.md +10 -0
- package/.opencode/commands/review.md +7 -1
- package/.opencode/commands/ship.md +95 -47
- package/.opencode/commands/status.md +42 -0
- package/.opencode/commands/validate.md +7 -1
- package/.opencode/commands/verify.md +52 -4
- package/.roo/commands/dev.md +6 -1
- package/.roo/commands/plan.md +59 -14
- package/.roo/commands/premerge.md +10 -0
- package/.roo/commands/review.md +7 -1
- package/.roo/commands/ship.md +95 -47
- package/.roo/commands/status.md +42 -0
- package/.roo/commands/validate.md +7 -1
- package/.roo/commands/verify.md +52 -4
- package/AGENTS.md +97 -0
- package/CLAUDE.md +10 -0
- package/README.md +2 -2
- package/bin/forge-cmd.js +5 -1
- package/bin/forge-preflight.js +15 -2
- package/bin/forge.js +178 -9
- package/docs/ENHANCED_ONBOARDING.md +96 -86
- package/docs/ROADMAP.md +2 -2
- package/docs/TOOLCHAIN.md +23 -0
- package/docs/VALIDATION.md +1 -1
- package/lefthook.yml +11 -0
- package/lib/agents-config.js +2 -2
- package/lib/commands/_registry.js +134 -0
- package/lib/commands/clean.js +181 -0
- package/lib/commands/dev.js +58 -0
- package/lib/commands/push.js +196 -0
- package/lib/commands/recommend.js +1 -1
- package/lib/commands/sync.js +55 -0
- package/lib/commands/team.js +37 -0
- package/lib/commands/test.js +207 -0
- package/lib/commands/worktree.js +310 -0
- package/lib/docs-command.js +51 -0
- package/lib/docs-copy.js +50 -0
- package/lib/freshness-token.js +148 -0
- package/lib/greptile-match.js +80 -0
- package/lib/reset.js +309 -0
- package/lib/task-ownership.js +117 -0
- package/package.json +2 -2
- package/scripts/beads-context.sh +157 -22
- package/scripts/beads-context.test.js +5 -1
- package/scripts/check-forge-token.js +98 -0
- package/scripts/conflict-detect.sh +2 -2
- package/scripts/dep-guard.sh +6 -28
- package/scripts/file-index.sh +117 -23
- package/scripts/forge-team/index.sh +86 -0
- package/scripts/forge-team/lib/agent-prompt.sh +52 -0
- package/scripts/forge-team/lib/claim.sh +256 -0
- package/scripts/forge-team/lib/dashboard.sh +341 -0
- package/scripts/forge-team/lib/epic.sh +332 -0
- package/scripts/forge-team/lib/hooks.sh +253 -0
- package/scripts/forge-team/lib/identity.sh +235 -0
- package/scripts/forge-team/lib/sync-github.sh +317 -0
- package/scripts/forge-team/lib/verify.sh +284 -0
- package/scripts/forge-team/lib/workload.sh +296 -0
- package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
- package/scripts/forge-team/tests/claim.test.sh +179 -0
- package/scripts/forge-team/tests/dashboard.test.sh +170 -0
- package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
- package/scripts/forge-team/tests/epic.test.sh +176 -0
- package/scripts/forge-team/tests/hooks.test.sh +239 -0
- package/scripts/forge-team/tests/identity.test.sh +176 -0
- package/scripts/forge-team/tests/integration.test.sh +371 -0
- package/scripts/forge-team/tests/sync-github.test.sh +209 -0
- package/scripts/forge-team/tests/verify.test.sh +314 -0
- package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
- package/scripts/forge-team/tests/workload.test.sh +209 -0
- package/scripts/lib/eval-runner.js +39 -0
- package/scripts/lib/jsonl-lock.sh +48 -0
- package/scripts/lib/sanitize.sh +116 -0
- package/scripts/pr-coordinator.sh +706 -0
- package/scripts/smart-status.sh +37 -10
- package/scripts/sync-utils.sh +24 -29
- package/scripts/test.js +3 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
|
|
6
|
+
# ── Setup temp dir for test isolation ────────────────────────────────────
|
|
7
|
+
TEST_TMP="$(mktemp -d)"
|
|
8
|
+
trap 'rm -rf "$TEST_TMP"' EXIT
|
|
9
|
+
|
|
10
|
+
# Override TEAM_MAP_ROOT so tests don't touch real .beads/
|
|
11
|
+
export TEAM_MAP_ROOT="$TEST_TMP"
|
|
12
|
+
|
|
13
|
+
# Fix "now" so stale detection is deterministic
|
|
14
|
+
# forge-bbb updated 2026-03-25T10:00:00Z → 50h ago at this fixed time
|
|
15
|
+
export WORKLOAD_NOW="2026-03-27T12:00:00Z"
|
|
16
|
+
|
|
17
|
+
# ── Create mock gh ───────────────────────────────────────────────────────
|
|
18
|
+
mock_dir="$(mktemp -d)"
|
|
19
|
+
|
|
20
|
+
cat > "$mock_dir/gh" << 'MOCK'
|
|
21
|
+
#!/usr/bin/env bash
|
|
22
|
+
case "$1 $2" in
|
|
23
|
+
"api user")
|
|
24
|
+
echo "devone"
|
|
25
|
+
;;
|
|
26
|
+
esac
|
|
27
|
+
MOCK
|
|
28
|
+
chmod +x "$mock_dir/gh"
|
|
29
|
+
|
|
30
|
+
# ── Create mock bd ───────────────────────────────────────────────────────
|
|
31
|
+
cat > "$mock_dir/bd" << 'MOCK'
|
|
32
|
+
#!/usr/bin/env bash
|
|
33
|
+
case "$1 $2" in
|
|
34
|
+
"list --status=open,in_progress")
|
|
35
|
+
echo "◐ forge-aaa · Feature A"
|
|
36
|
+
echo "○ forge-bbb · Feature B"
|
|
37
|
+
echo "◐ forge-ccc · Feature C"
|
|
38
|
+
;;
|
|
39
|
+
"show forge-aaa")
|
|
40
|
+
echo "◐ forge-aaa · Feature A [● P2 · IN_PROGRESS]"
|
|
41
|
+
echo "Owner: devone"
|
|
42
|
+
echo "Updated: 2026-03-27T10:00:00Z"
|
|
43
|
+
;;
|
|
44
|
+
"show forge-bbb")
|
|
45
|
+
echo "○ forge-bbb · Feature B [● P2 · OPEN]"
|
|
46
|
+
echo "Owner: devtwo"
|
|
47
|
+
echo "Updated: 2026-03-25T10:00:00Z"
|
|
48
|
+
;;
|
|
49
|
+
"show forge-ccc")
|
|
50
|
+
echo "◐ forge-ccc · Feature C [● P1 · IN_PROGRESS]"
|
|
51
|
+
echo "Owner: devone"
|
|
52
|
+
echo "Updated: 2026-03-27T09:00:00Z"
|
|
53
|
+
echo ""
|
|
54
|
+
echo "DEPENDS ON"
|
|
55
|
+
echo " → forge-aaa: Feature A"
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
MOCK
|
|
59
|
+
chmod +x "$mock_dir/bd"
|
|
60
|
+
|
|
61
|
+
# ── Create mock bd with no issues ────────────────────────────────────────
|
|
62
|
+
cat > "$mock_dir/bd-empty" << 'MOCK'
|
|
63
|
+
#!/usr/bin/env bash
|
|
64
|
+
case "$1 $2" in
|
|
65
|
+
"list --status=open,in_progress")
|
|
66
|
+
echo ""
|
|
67
|
+
;;
|
|
68
|
+
esac
|
|
69
|
+
MOCK
|
|
70
|
+
chmod +x "$mock_dir/bd-empty"
|
|
71
|
+
|
|
72
|
+
export GH_CMD="$mock_dir/gh"
|
|
73
|
+
export BD_CMD="$mock_dir/bd"
|
|
74
|
+
|
|
75
|
+
# Source the library under test
|
|
76
|
+
source "$SCRIPT_DIR/lib/workload.sh"
|
|
77
|
+
|
|
78
|
+
PASS=0
|
|
79
|
+
FAIL=0
|
|
80
|
+
|
|
81
|
+
assert_eq() {
|
|
82
|
+
local label="$1" expected="$2" actual="$3"
|
|
83
|
+
if [[ "$actual" == "$expected" ]]; then
|
|
84
|
+
PASS=$((PASS + 1)); echo " PASS: $label"
|
|
85
|
+
else
|
|
86
|
+
FAIL=$((FAIL + 1)); echo " FAIL: $label (expected '$expected', got '$actual')"
|
|
87
|
+
fi
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
assert_contains() {
|
|
91
|
+
local label="$1" expected="$2" actual="$3"
|
|
92
|
+
if [[ "$actual" == *"$expected"* ]]; then
|
|
93
|
+
PASS=$((PASS + 1)); echo " PASS: $label"
|
|
94
|
+
else
|
|
95
|
+
FAIL=$((FAIL + 1)); echo " FAIL: $label (expected to contain '$expected', got '$actual')"
|
|
96
|
+
fi
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
assert_not_contains() {
|
|
100
|
+
local label="$1" unexpected="$2" actual="$3"
|
|
101
|
+
if [[ "$actual" != *"$unexpected"* ]]; then
|
|
102
|
+
PASS=$((PASS + 1)); echo " PASS: $label"
|
|
103
|
+
else
|
|
104
|
+
FAIL=$((FAIL + 1)); echo " FAIL: $label (expected NOT to contain '$unexpected', got '$actual')"
|
|
105
|
+
fi
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
assert_exit() {
|
|
109
|
+
local label="$1" expected="$2" actual="$3"
|
|
110
|
+
if [[ "$actual" -eq "$expected" ]]; then
|
|
111
|
+
PASS=$((PASS + 1)); echo " PASS: $label"
|
|
112
|
+
else
|
|
113
|
+
FAIL=$((FAIL + 1)); echo " FAIL: $label (expected exit $expected, got $actual)"
|
|
114
|
+
fi
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# ── Test 1: All developers shown with grouped issues ─────────────────────
|
|
118
|
+
echo "── Test 1: All developers shown with grouped issues ──"
|
|
119
|
+
unset _GITHUB_USER_CACHE
|
|
120
|
+
rc=0
|
|
121
|
+
output="$(cmd_workload 2>/dev/null)" || rc=$?
|
|
122
|
+
assert_exit "exits 0" 0 "$rc"
|
|
123
|
+
assert_contains "shows devone header" "Developer: devone" "$output"
|
|
124
|
+
assert_contains "shows devtwo header" "Developer: devtwo" "$output"
|
|
125
|
+
assert_contains "shows forge-aaa" "forge-aaa" "$output"
|
|
126
|
+
assert_contains "shows forge-bbb" "forge-bbb" "$output"
|
|
127
|
+
assert_contains "shows forge-ccc" "forge-ccc" "$output"
|
|
128
|
+
|
|
129
|
+
# ── Test 2: --developer=devone filters correctly ─────────────────────────
|
|
130
|
+
echo ""
|
|
131
|
+
echo "── Test 2: --developer=devone filters correctly ──"
|
|
132
|
+
unset _GITHUB_USER_CACHE
|
|
133
|
+
rc=0
|
|
134
|
+
output="$(cmd_workload --developer=devone 2>/dev/null)" || rc=$?
|
|
135
|
+
assert_exit "exits 0" 0 "$rc"
|
|
136
|
+
assert_contains "shows devone" "Developer: devone" "$output"
|
|
137
|
+
assert_not_contains "excludes devtwo" "Developer: devtwo" "$output"
|
|
138
|
+
assert_contains "shows forge-aaa" "forge-aaa" "$output"
|
|
139
|
+
assert_contains "shows forge-ccc" "forge-ccc" "$output"
|
|
140
|
+
assert_not_contains "excludes forge-bbb" "forge-bbb" "$output"
|
|
141
|
+
|
|
142
|
+
# ── Test 3: --me uses get_github_user() to filter ────────────────────────
|
|
143
|
+
echo ""
|
|
144
|
+
echo "── Test 3: --me uses get_github_user() to filter ──"
|
|
145
|
+
unset _GITHUB_USER_CACHE
|
|
146
|
+
rc=0
|
|
147
|
+
output="$(cmd_workload --me 2>/dev/null)" || rc=$?
|
|
148
|
+
assert_exit "exits 0" 0 "$rc"
|
|
149
|
+
assert_contains "shows devone (current user)" "Developer: devone" "$output"
|
|
150
|
+
assert_not_contains "excludes devtwo" "Developer: devtwo" "$output"
|
|
151
|
+
|
|
152
|
+
# ── Test 4: No issues → "No active work" ─────────────────────────────────
|
|
153
|
+
echo ""
|
|
154
|
+
echo "── Test 4: No issues → No active work ──"
|
|
155
|
+
unset _GITHUB_USER_CACHE
|
|
156
|
+
export BD_CMD="$mock_dir/bd-empty"
|
|
157
|
+
rc=0
|
|
158
|
+
output="$(cmd_workload 2>/dev/null)" || rc=$?
|
|
159
|
+
assert_exit "exits 0" 0 "$rc"
|
|
160
|
+
assert_contains "shows no active work message" "No active work" "$output"
|
|
161
|
+
export BD_CMD="$mock_dir/bd"
|
|
162
|
+
|
|
163
|
+
# ── Test 5: Stale assignment flagged (>48h) ──────────────────────────────
|
|
164
|
+
echo ""
|
|
165
|
+
echo "── Test 5: Stale assignment flagged (>48h) ──"
|
|
166
|
+
unset _GITHUB_USER_CACHE
|
|
167
|
+
rc=0
|
|
168
|
+
output="$(cmd_workload 2>/dev/null)" || rc=$?
|
|
169
|
+
assert_exit "exits 0" 0 "$rc"
|
|
170
|
+
# forge-bbb was updated 2026-03-25T10:00:00Z, which is >48h ago from "now"
|
|
171
|
+
assert_contains "stale flag on forge-bbb" "stale:" "$output"
|
|
172
|
+
|
|
173
|
+
# ── Test 6: --format=json returns valid JSON ─────────────────────────────
|
|
174
|
+
echo ""
|
|
175
|
+
echo "── Test 6: --format=json returns valid JSON ──"
|
|
176
|
+
unset _GITHUB_USER_CACHE
|
|
177
|
+
rc=0
|
|
178
|
+
output="$(cmd_workload --format=json 2>/dev/null)" || rc=$?
|
|
179
|
+
assert_exit "exits 0" 0 "$rc"
|
|
180
|
+
# Validate it's parseable JSON
|
|
181
|
+
if echo "$output" | jq . >/dev/null 2>&1; then
|
|
182
|
+
PASS=$((PASS + 1)); echo " PASS: output is valid JSON"
|
|
183
|
+
else
|
|
184
|
+
FAIL=$((FAIL + 1)); echo " FAIL: output is not valid JSON: $output"
|
|
185
|
+
fi
|
|
186
|
+
# Check JSON has developer keys
|
|
187
|
+
if echo "$output" | jq -e '.devone' >/dev/null 2>&1; then
|
|
188
|
+
PASS=$((PASS + 1)); echo " PASS: JSON contains devone key"
|
|
189
|
+
else
|
|
190
|
+
FAIL=$((FAIL + 1)); echo " FAIL: JSON missing devone key"
|
|
191
|
+
fi
|
|
192
|
+
if echo "$output" | jq -e '.devtwo' >/dev/null 2>&1; then
|
|
193
|
+
PASS=$((PASS + 1)); echo " PASS: JSON contains devtwo key"
|
|
194
|
+
else
|
|
195
|
+
FAIL=$((FAIL + 1)); echo " FAIL: JSON missing devtwo key"
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
# ── Test 7: Blocked issue flagged ─────────────────────────────────────────
|
|
199
|
+
echo ""
|
|
200
|
+
echo "── Test 7: Blocked issue flagged ──"
|
|
201
|
+
unset _GITHUB_USER_CACHE
|
|
202
|
+
rc=0
|
|
203
|
+
output="$(cmd_workload 2>/dev/null)" || rc=$?
|
|
204
|
+
assert_contains "blocked flag on forge-ccc" "blocked by forge-aaa" "$output"
|
|
205
|
+
|
|
206
|
+
# ── Results ──────────────────────────────────────────────────────────────
|
|
207
|
+
echo ""
|
|
208
|
+
echo "Results: $PASS passed, $FAIL failed"
|
|
209
|
+
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
|
|
@@ -11,6 +11,42 @@
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { execSync } = require('node:child_process');
|
|
13
13
|
|
|
14
|
+
// ── active worktree tracking (cleanup on crash) ─────────────────────
|
|
15
|
+
// Tracks active eval worktrees so we can clean up on process exit/crash.
|
|
16
|
+
// Prevents orphaned eval-* branches when interrupted.
|
|
17
|
+
// Note: execSync is safe here — all paths are internally generated, never user input.
|
|
18
|
+
const activeEvalWorktrees = new Map(); // path -> branch
|
|
19
|
+
|
|
20
|
+
function cleanupActiveWorktrees() {
|
|
21
|
+
if (activeEvalWorktrees.size === 0) return;
|
|
22
|
+
let repoRoot;
|
|
23
|
+
try { repoRoot = getRepoRoot(); } catch (_err) { return; }
|
|
24
|
+
for (const [wtPath, branch] of activeEvalWorktrees) {
|
|
25
|
+
try {
|
|
26
|
+
execSync(`git worktree remove --force "${wtPath}"`, { cwd: repoRoot, stdio: 'pipe' });
|
|
27
|
+
} catch (_err) { /* already removed */ }
|
|
28
|
+
if (branch && branch.startsWith('eval-')) {
|
|
29
|
+
try {
|
|
30
|
+
execSync(`git branch -D "${branch}"`, { cwd: repoRoot, stdio: 'pipe' });
|
|
31
|
+
} catch (_err) { /* already deleted */ }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
try { execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' }); } catch (_err) { /* ignore */ }
|
|
35
|
+
activeEvalWorktrees.clear();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
process.on('exit', cleanupActiveWorktrees);
|
|
39
|
+
process.on('SIGINT', () => {
|
|
40
|
+
const hadWork = activeEvalWorktrees.size > 0;
|
|
41
|
+
cleanupActiveWorktrees();
|
|
42
|
+
if (hadWork) process.exit(130);
|
|
43
|
+
});
|
|
44
|
+
process.on('SIGTERM', () => {
|
|
45
|
+
const hadWork = activeEvalWorktrees.size > 0;
|
|
46
|
+
cleanupActiveWorktrees();
|
|
47
|
+
if (hadWork) process.exit(143);
|
|
48
|
+
});
|
|
49
|
+
|
|
14
50
|
// ── helpers ──────────────────────────────────────────────────────────
|
|
15
51
|
|
|
16
52
|
/**
|
|
@@ -56,6 +92,7 @@ async function createEvalWorktree() {
|
|
|
56
92
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
57
93
|
});
|
|
58
94
|
|
|
95
|
+
activeEvalWorktrees.set(wtPath, branch);
|
|
59
96
|
return { path: wtPath, branch };
|
|
60
97
|
}
|
|
61
98
|
|
|
@@ -98,6 +135,8 @@ async function destroyEvalWorktree(worktreePath) {
|
|
|
98
135
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
99
136
|
});
|
|
100
137
|
|
|
138
|
+
activeEvalWorktrees.delete(worktreePath);
|
|
139
|
+
|
|
101
140
|
// Delete the temporary branch (force in case it's not fully merged)
|
|
102
141
|
if (branch && branch.startsWith('eval-')) {
|
|
103
142
|
try {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/lib/jsonl-lock.sh — Atomic JSONL append with file locking
|
|
3
|
+
#
|
|
4
|
+
# Provides atomic_jsonl_append() for concurrent-safe JSONL file writes.
|
|
5
|
+
# Cross-platform: uses flock on Linux/WSL/Git Bash, mkdir-based fallback on macOS.
|
|
6
|
+
#
|
|
7
|
+
# Usage (source this file):
|
|
8
|
+
# source scripts/lib/jsonl-lock.sh
|
|
9
|
+
# atomic_jsonl_append "/path/to/file.jsonl" '{"key":"value"}'
|
|
10
|
+
#
|
|
11
|
+
# Exit codes: 0=success, 1=lock timeout or write failure
|
|
12
|
+
# Lock timeout: 5 seconds
|
|
13
|
+
|
|
14
|
+
# atomic_jsonl_append <jsonl_file> <json_line>
|
|
15
|
+
# Appends a JSON line to a JSONL file with file locking.
|
|
16
|
+
# Lock file: <jsonl_file>.lock (flock) or <jsonl_file>.lock.d (mkdir)
|
|
17
|
+
# Timeout: 5 seconds
|
|
18
|
+
# Exit codes: 0=success, 1=lock timeout or write failure
|
|
19
|
+
atomic_jsonl_append() {
|
|
20
|
+
local jsonl_file="$1"
|
|
21
|
+
local json_line="$2"
|
|
22
|
+
|
|
23
|
+
mkdir -p "$(dirname "$jsonl_file")"
|
|
24
|
+
|
|
25
|
+
if command -v flock &>/dev/null; then
|
|
26
|
+
# flock-based locking (Linux, WSL, Git Bash)
|
|
27
|
+
local lock_file="${jsonl_file}.lock"
|
|
28
|
+
(
|
|
29
|
+
flock -w 5 200 || { echo "Error: JSONL lock timeout after 5s" >&2; return 1; }
|
|
30
|
+
printf '%s\n' "$json_line" >> "$jsonl_file"
|
|
31
|
+
) 200>"$lock_file"
|
|
32
|
+
else
|
|
33
|
+
# mkdir-based fallback (macOS, systems without flock)
|
|
34
|
+
local lock_file="${jsonl_file}.lock.d"
|
|
35
|
+
local attempts=0
|
|
36
|
+
while ! mkdir "$lock_file" 2>/dev/null; do
|
|
37
|
+
attempts=$((attempts + 1))
|
|
38
|
+
if [[ $attempts -ge 50 ]]; then # 50 * 0.1s = 5s timeout
|
|
39
|
+
echo "Error: JSONL lock timeout after 5s" >&2
|
|
40
|
+
return 1
|
|
41
|
+
fi
|
|
42
|
+
sleep 0.1
|
|
43
|
+
done
|
|
44
|
+
# RETURN trap handles cleanup on both success and failure
|
|
45
|
+
trap 'rmdir "$lock_file" 2>/dev/null; trap - RETURN' RETURN
|
|
46
|
+
printf '%s\n' "$json_line" >> "$jsonl_file"
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# sanitize.sh — Shared input sanitization and validation library.
|
|
3
|
+
#
|
|
4
|
+
# Extracted from file-index.sh, dep-guard.sh, beads-context.sh, sync-utils.sh
|
|
5
|
+
# to eliminate duplication. Source this file; do NOT run it directly.
|
|
6
|
+
#
|
|
7
|
+
# Functions:
|
|
8
|
+
# sanitize <string> Strip shell-injection patterns (OWASP A03)
|
|
9
|
+
# sanitize_config_value <string> Strip injection + pipes, trim whitespace
|
|
10
|
+
# validate_branch_name <string> Validate git branch name format
|
|
11
|
+
# validate_pr_number <string> Validate GitHub PR number (digits only)
|
|
12
|
+
# validate_label_name <string> Validate GitHub label format
|
|
13
|
+
#
|
|
14
|
+
# This file does NOT set errexit/pipefail — callers manage their own shell options.
|
|
15
|
+
# OWASP A03: All inputs validated and shell-injection patterns stripped.
|
|
16
|
+
|
|
17
|
+
# Guard against double-sourcing
|
|
18
|
+
if [[ -n "${_SANITIZE_LIB_LOADED:-}" ]]; then
|
|
19
|
+
return 0 2>/dev/null || true
|
|
20
|
+
fi
|
|
21
|
+
_SANITIZE_LIB_LOADED=1
|
|
22
|
+
|
|
23
|
+
# ── sanitize ──────────────────────────────────────────────────────────────
|
|
24
|
+
# Sanitize a string: strip shell-injection patterns (OWASP A03).
|
|
25
|
+
# Removes: double quotes, $(...), backticks, semicolons, and newlines.
|
|
26
|
+
# Usage: clean="$(sanitize "$raw")"
|
|
27
|
+
sanitize() {
|
|
28
|
+
local val="$1"
|
|
29
|
+
# Remove double quotes
|
|
30
|
+
val="${val//\"/}"
|
|
31
|
+
# Remove $(...) command substitution patterns (loop handles nested)
|
|
32
|
+
val="$(printf '%s' "$val" | sed -e ':loop' -e 's/\$([^()]*)//g' -e 't loop')"
|
|
33
|
+
# Remove backtick command substitution
|
|
34
|
+
val="${val//\`/}"
|
|
35
|
+
# Remove semicolons (command chaining)
|
|
36
|
+
val="${val//;/}"
|
|
37
|
+
# Replace newlines with spaces
|
|
38
|
+
val="$(printf '%s' "$val" | tr '\n' ' ')"
|
|
39
|
+
printf '%s' "$val"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# ── sanitize_config_value ────────────────────────────────────────────────
|
|
43
|
+
# Sanitize a config value: strip shell-injection patterns + pipes (OWASP A03).
|
|
44
|
+
# Removes: backticks, $(...), semicolons, pipes, newlines. Trims whitespace.
|
|
45
|
+
# Usage: clean="$(sanitize_config_value "$raw")"
|
|
46
|
+
sanitize_config_value() {
|
|
47
|
+
local val="$1"
|
|
48
|
+
# Remove backtick command substitution
|
|
49
|
+
val="${val//\`/}"
|
|
50
|
+
# Remove $(...) command substitution patterns (loop handles nested)
|
|
51
|
+
val="$(printf '%s' "$val" | sed -e ':loop' -e 's/\$([^()]*)//g' -e 't loop')"
|
|
52
|
+
# Remove semicolons (command chaining)
|
|
53
|
+
val="${val//;/}"
|
|
54
|
+
# Remove pipes (command chaining)
|
|
55
|
+
val="${val//|/}"
|
|
56
|
+
# Collapse newlines to spaces
|
|
57
|
+
val="$(printf '%s' "$val" | tr '\n' ' ')"
|
|
58
|
+
# Trim leading/trailing whitespace
|
|
59
|
+
val="$(printf '%s' "$val" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
60
|
+
printf '%s' "$val"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# ── validate_branch_name ─────────────────────────────────────────────────
|
|
64
|
+
# Validate a git branch name.
|
|
65
|
+
# Allowed: alphanumeric, dots, hyphens, underscores, forward slashes, @
|
|
66
|
+
# Usage: validate_branch_name "feat/my-feature" || exit 1
|
|
67
|
+
validate_branch_name() {
|
|
68
|
+
local name="${1:-}"
|
|
69
|
+
if [[ -z "$name" ]]; then
|
|
70
|
+
echo "Error: branch name cannot be empty" >&2
|
|
71
|
+
return 1
|
|
72
|
+
fi
|
|
73
|
+
if [[ ! "$name" =~ ^[a-zA-Z0-9._/@-]+$ ]]; then
|
|
74
|
+
echo "Error: invalid branch name format: $name" >&2
|
|
75
|
+
return 1
|
|
76
|
+
fi
|
|
77
|
+
# Git rejects: double dots (..), trailing .lock, leading/trailing slash, leading hyphen
|
|
78
|
+
if [[ "$name" == *..* ]] || [[ "$name" == *.lock ]] || [[ "$name" == /* ]] || [[ "$name" == */ ]] || [[ "$name" == -* ]]; then
|
|
79
|
+
echo "Error: invalid branch name (contains .., .lock suffix, leading/trailing /, or leading -): $name" >&2
|
|
80
|
+
return 1
|
|
81
|
+
fi
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# ── validate_pr_number ───────────────────────────────────────────────────
|
|
86
|
+
# Validate a GitHub PR number (digits only).
|
|
87
|
+
# Usage: validate_pr_number "42" || exit 1
|
|
88
|
+
validate_pr_number() {
|
|
89
|
+
local num="${1:-}"
|
|
90
|
+
if [[ -z "$num" ]]; then
|
|
91
|
+
echo "Error: PR number cannot be empty" >&2
|
|
92
|
+
return 1
|
|
93
|
+
fi
|
|
94
|
+
if [[ ! "$num" =~ ^[0-9]+$ ]]; then
|
|
95
|
+
echo "Error: invalid PR number format: $num" >&2
|
|
96
|
+
return 1
|
|
97
|
+
fi
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# ── validate_label_name ──────────────────────────────────────────────────
|
|
102
|
+
# Validate a GitHub label name.
|
|
103
|
+
# Allowed: alphanumeric, dots, hyphens, underscores, forward slashes
|
|
104
|
+
# Usage: validate_label_name "forge/has-deps" || exit 1
|
|
105
|
+
validate_label_name() {
|
|
106
|
+
local name="${1:-}"
|
|
107
|
+
if [[ -z "$name" ]]; then
|
|
108
|
+
echo "Error: label name cannot be empty" >&2
|
|
109
|
+
return 1
|
|
110
|
+
fi
|
|
111
|
+
if [[ ! "$name" =~ ^[a-zA-Z0-9._/-]+$ ]]; then
|
|
112
|
+
echo "Error: invalid label name format: $name" >&2
|
|
113
|
+
return 1
|
|
114
|
+
fi
|
|
115
|
+
return 0
|
|
116
|
+
}
|