soloship 0.1.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 +252 -0
- package/bin/soloship.js +2 -0
- package/dist/artifacts.d.ts +83 -0
- package/dist/artifacts.js +241 -0
- package/dist/ci.d.ts +2 -0
- package/dist/ci.js +184 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +63 -0
- package/dist/detect.d.ts +30 -0
- package/dist/detect.js +127 -0
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.js +205 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +477 -0
- package/dist/init.d.ts +5 -0
- package/dist/init.js +94 -0
- package/dist/manifest.d.ts +63 -0
- package/dist/manifest.js +90 -0
- package/dist/pkg.d.ts +1 -0
- package/dist/pkg.js +9 -0
- package/dist/rollback.d.ts +12 -0
- package/dist/rollback.js +129 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +119 -0
- package/dist/scaffold.d.ts +7 -0
- package/dist/scaffold.js +138 -0
- package/dist/templates.d.ts +5 -0
- package/dist/templates.js +175 -0
- package/dist/upgrade.d.ts +12 -0
- package/dist/upgrade.js +62 -0
- package/package.json +38 -0
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export async function installHooks(root, project) {
|
|
4
|
+
const results = [];
|
|
5
|
+
const claudeDir = join(root, ".claude");
|
|
6
|
+
if (!existsSync(claudeDir)) {
|
|
7
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
const settingsPath = join(claudeDir, "settings.local.json");
|
|
10
|
+
// Read existing settings or start fresh
|
|
11
|
+
let settings = {};
|
|
12
|
+
if (existsSync(settingsPath)) {
|
|
13
|
+
try {
|
|
14
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Invalid JSON, start fresh
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Build hooks config
|
|
21
|
+
const hooks = {};
|
|
22
|
+
// PreToolUse: Block dangerous commands + phone-a-friend warnings
|
|
23
|
+
hooks.PreToolUse = [
|
|
24
|
+
{
|
|
25
|
+
matcher: "Bash",
|
|
26
|
+
hooks: [
|
|
27
|
+
{
|
|
28
|
+
type: "command",
|
|
29
|
+
command: buildPreToolUseScript(),
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
matcher: "Bash",
|
|
36
|
+
hooks: [
|
|
37
|
+
{
|
|
38
|
+
type: "command",
|
|
39
|
+
command: buildPhoneAFriendScript(),
|
|
40
|
+
timeout: 10000,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
matcher: "Bash",
|
|
46
|
+
hooks: [
|
|
47
|
+
{
|
|
48
|
+
type: "command",
|
|
49
|
+
command: buildSecurityScanScript(),
|
|
50
|
+
timeout: 30000,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
results.push("PreToolUse: block dangerous commands (rm -rf ~, .env edits, force push to main)");
|
|
56
|
+
results.push("PreToolUse: phone-a-friend warnings on commits (6 heuristic patterns)");
|
|
57
|
+
results.push("PreToolUse: security scan on commits (Semgrep, blocks critical findings)");
|
|
58
|
+
// PostToolUse: Auto-lint after file edits + CHANGELOG check after commits
|
|
59
|
+
const postToolUseHooks = [];
|
|
60
|
+
if (project.stack.hasLinter) {
|
|
61
|
+
postToolUseHooks.push({
|
|
62
|
+
matcher: "Edit|Write",
|
|
63
|
+
hooks: [
|
|
64
|
+
{
|
|
65
|
+
type: "command",
|
|
66
|
+
command: buildPostToolUseLintScript(project),
|
|
67
|
+
timeout: 10000,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
results.push("PostToolUse: auto-lint after file edits");
|
|
72
|
+
}
|
|
73
|
+
// CHANGELOG check: warn if feat/fix/refactor commit lacks CHANGELOG entry
|
|
74
|
+
postToolUseHooks.push({
|
|
75
|
+
matcher: "Bash",
|
|
76
|
+
hooks: [
|
|
77
|
+
{
|
|
78
|
+
type: "command",
|
|
79
|
+
command: buildChangelogCheckScript(),
|
|
80
|
+
timeout: 5000,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
results.push("PostToolUse: CHANGELOG check for feat/fix/refactor commits");
|
|
85
|
+
if (postToolUseHooks.length > 0) {
|
|
86
|
+
hooks.PostToolUse = postToolUseHooks;
|
|
87
|
+
}
|
|
88
|
+
// Stop: Plan validation + dependency graph + workflow navigator + handoff reminder
|
|
89
|
+
hooks.Stop = [
|
|
90
|
+
{
|
|
91
|
+
matcher: "",
|
|
92
|
+
hooks: [
|
|
93
|
+
{
|
|
94
|
+
type: "command",
|
|
95
|
+
command: buildStopScript(project),
|
|
96
|
+
timeout: 15000,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
results.push("Stop: plan validation + workflow navigator + handoff reminder");
|
|
102
|
+
// SessionStart: Checkpoint commit + context injection
|
|
103
|
+
hooks.SessionStart = [
|
|
104
|
+
{
|
|
105
|
+
matcher: "",
|
|
106
|
+
hooks: [
|
|
107
|
+
{
|
|
108
|
+
type: "command",
|
|
109
|
+
command: buildCheckpointScript(),
|
|
110
|
+
timeout: 15000,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
matcher: "",
|
|
116
|
+
hooks: [
|
|
117
|
+
{
|
|
118
|
+
type: "command",
|
|
119
|
+
command: buildSessionStartScript(),
|
|
120
|
+
timeout: 10000,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
results.push("SessionStart: checkpoint commit before agent session");
|
|
126
|
+
results.push("SessionStart: context injection");
|
|
127
|
+
// Merge hooks into settings (don't overwrite other settings)
|
|
128
|
+
settings.hooks = hooks;
|
|
129
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
130
|
+
results.push(`Written to .claude/settings.local.json`);
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
133
|
+
function buildPreToolUseScript() {
|
|
134
|
+
// Exit code 2 blocks the action
|
|
135
|
+
return `bash -c '
|
|
136
|
+
COMMAND="$HOOK_TOOL_INPUT"
|
|
137
|
+
|
|
138
|
+
# Block rm -rf with home directory
|
|
139
|
+
if echo "$COMMAND" | grep -qE "rm\\s+-rf\\s+(~|/Users|/home|\\$HOME)"; then
|
|
140
|
+
echo "BLOCKED: Dangerous rm -rf targeting home directory" >&2
|
|
141
|
+
exit 2
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
# Block .env edits
|
|
145
|
+
if echo "$COMMAND" | grep -qE "(cat|echo|printf|>).*\\.env($|\\s)"; then
|
|
146
|
+
echo "BLOCKED: Direct .env file modification" >&2
|
|
147
|
+
exit 2
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
# Block force push to main/master
|
|
151
|
+
if echo "$COMMAND" | grep -qE "git\\s+push.*--force.*(main|master)"; then
|
|
152
|
+
echo "BLOCKED: Force push to main/master" >&2
|
|
153
|
+
exit 2
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# Block hardcoded API keys
|
|
157
|
+
if echo "$COMMAND" | grep -qE "(ANTHROPIC|OPENAI|STRIPE|FIREBASE)_.*_KEY.*=.*[a-zA-Z0-9]{20}"; then
|
|
158
|
+
echo "BLOCKED: Possible hardcoded API key" >&2
|
|
159
|
+
exit 2
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
exit 0
|
|
163
|
+
'`;
|
|
164
|
+
}
|
|
165
|
+
function buildPostToolUseLintScript(project) {
|
|
166
|
+
const lintCmd = project.stack.hasLinter
|
|
167
|
+
? project.stack.packageManager === "bun"
|
|
168
|
+
? "bunx eslint --fix"
|
|
169
|
+
: "npx eslint --fix"
|
|
170
|
+
: "true";
|
|
171
|
+
return `bash -c '
|
|
172
|
+
# Only lint if a source file was modified
|
|
173
|
+
FILE="$HOOK_MODIFIED_FILE"
|
|
174
|
+
if [ -n "$FILE" ] && echo "$FILE" | grep -qE "\\.(ts|tsx|js|jsx)$"; then
|
|
175
|
+
${lintCmd} "$FILE" 2>/dev/null || true
|
|
176
|
+
fi
|
|
177
|
+
'`;
|
|
178
|
+
}
|
|
179
|
+
function buildChangelogCheckScript() {
|
|
180
|
+
return `bash -c '
|
|
181
|
+
# Only check if the last command was a git commit
|
|
182
|
+
COMMAND="$HOOK_TOOL_INPUT"
|
|
183
|
+
if echo "$COMMAND" | grep -qE "git\\s+commit"; then
|
|
184
|
+
# Get the most recent commit message
|
|
185
|
+
MSG=$(git log -1 --pretty=%s 2>/dev/null)
|
|
186
|
+
# Only warn for feat/fix/refactor commits
|
|
187
|
+
if echo "$MSG" | grep -qE "^(feat|fix|refactor):"; then
|
|
188
|
+
# Check if CHANGELOG.md was modified in this commit
|
|
189
|
+
if ! git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | grep -q "CHANGELOG.md"; then
|
|
190
|
+
echo "{\\"systemMessage\\": \\"Warning: commit \\\\\"$MSG\\\\\" has no CHANGELOG.md entry. Consider adding one to the [Unreleased] section.\\"}"
|
|
191
|
+
fi
|
|
192
|
+
fi
|
|
193
|
+
fi
|
|
194
|
+
exit 0
|
|
195
|
+
'`;
|
|
196
|
+
}
|
|
197
|
+
function buildStopScript(project) {
|
|
198
|
+
return `bash -c '
|
|
199
|
+
MESSAGES=""
|
|
200
|
+
|
|
201
|
+
# Plan validation: check for Key Decisions and Why lines
|
|
202
|
+
for plan in docs/plans/$(date +%Y)*.md; do
|
|
203
|
+
if [ -f "$plan" ]; then
|
|
204
|
+
if ! grep -q "Key Decisions" "$plan" 2>/dev/null; then
|
|
205
|
+
MESSAGES="$MESSAGES Plan file $plan is missing a Key Decisions section."
|
|
206
|
+
break
|
|
207
|
+
fi
|
|
208
|
+
fi
|
|
209
|
+
done
|
|
210
|
+
|
|
211
|
+
# Dependency graph generation removed.
|
|
212
|
+
|
|
213
|
+
# Workflow navigator: detect what just happened and suggest next step
|
|
214
|
+
LAST_COMMIT=$(git log -1 --pretty=%s 2>/dev/null || true)
|
|
215
|
+
RECENT_PLANS=$(find docs/plans -maxdepth 1 -name "*.md" -newer docs/plans/archive -type f 2>/dev/null | head -1)
|
|
216
|
+
HAS_STAGED=$(git diff --cached --name-only 2>/dev/null | head -1)
|
|
217
|
+
HAS_UNSTAGED=$(git diff --name-only 2>/dev/null | head -1)
|
|
218
|
+
|
|
219
|
+
# If a plan was just written, suggest next step
|
|
220
|
+
if [ -n "$RECENT_PLANS" ] && [ -f "$RECENT_PLANS" ]; then
|
|
221
|
+
PLAN_AGE=$(( $(date +%s) - $(stat -f %m "$RECENT_PLANS" 2>/dev/null || stat -c %Y "$RECENT_PLANS" 2>/dev/null || echo 0) ))
|
|
222
|
+
if [ "$PLAN_AGE" -lt 120 ]; then
|
|
223
|
+
MESSAGES="$MESSAGES Plan written. Design what it looks like, then run /soloship-implement to execute."
|
|
224
|
+
fi
|
|
225
|
+
fi
|
|
226
|
+
|
|
227
|
+
# If code was just committed, suggest ship or learn
|
|
228
|
+
if echo "$LAST_COMMIT" | grep -qE "^(feat|fix|refactor):" 2>/dev/null; then
|
|
229
|
+
COMMIT_AGE=$(( $(date +%s) - $(git log -1 --format=%ct 2>/dev/null || echo 0) ))
|
|
230
|
+
if [ "$COMMIT_AGE" -lt 120 ]; then
|
|
231
|
+
MESSAGES="$MESSAGES Code committed. Run /soloship-shipfast to deploy or /soloship-shipthorough for full review."
|
|
232
|
+
fi
|
|
233
|
+
fi
|
|
234
|
+
|
|
235
|
+
# Handoff reminder: if session has been active 30+ min, nudge for state capture
|
|
236
|
+
SESSION_FILE=".ai/.session-start"
|
|
237
|
+
if [ ! -f "$SESSION_FILE" ]; then
|
|
238
|
+
mkdir -p .ai
|
|
239
|
+
date +%s > "$SESSION_FILE"
|
|
240
|
+
fi
|
|
241
|
+
SESSION_START=$(cat "$SESSION_FILE" 2>/dev/null || echo 0)
|
|
242
|
+
NOW=$(date +%s)
|
|
243
|
+
ELAPSED=$(( NOW - SESSION_START ))
|
|
244
|
+
HANDOFF_FILE=".ai/.last-handoff"
|
|
245
|
+
LAST_HANDOFF=$(cat "$HANDOFF_FILE" 2>/dev/null || echo 0)
|
|
246
|
+
SINCE_HANDOFF=$(( NOW - LAST_HANDOFF ))
|
|
247
|
+
|
|
248
|
+
# Remind every 30 minutes
|
|
249
|
+
if [ "$ELAPSED" -gt 1800 ] && [ "$SINCE_HANDOFF" -gt 1800 ]; then
|
|
250
|
+
if [ -n "$HAS_STAGED" ] || [ -n "$HAS_UNSTAGED" ]; then
|
|
251
|
+
MESSAGES="$MESSAGES Session active 30+ min with uncommitted work. Consider writing a handoff note: state of work + next tiny action."
|
|
252
|
+
echo "$NOW" > "$HANDOFF_FILE"
|
|
253
|
+
fi
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
# Output combined message if any
|
|
257
|
+
if [ -n "$MESSAGES" ]; then
|
|
258
|
+
# Escape for JSON
|
|
259
|
+
ESCAPED=$(echo "$MESSAGES" | sed "s/\"/\\\\\\\\\"/g")
|
|
260
|
+
echo "{\\"systemMessage\\": \\"$ESCAPED\\"}"
|
|
261
|
+
fi
|
|
262
|
+
'`;
|
|
263
|
+
}
|
|
264
|
+
function buildCheckpointScript() {
|
|
265
|
+
// Creates a checkpoint at session start so the user can rollback.
|
|
266
|
+
// Saves HEAD commit SHA and, if uncommitted changes exist, a stash snapshot SHA.
|
|
267
|
+
// Uses git stash create — creates a snapshot commit object WITHOUT modifying
|
|
268
|
+
// the working directory or the stash list. Zero side effects.
|
|
269
|
+
return `bash -c '
|
|
270
|
+
# Only run in a git repo
|
|
271
|
+
if ! git rev-parse --is-inside-work-tree &>/dev/null; then
|
|
272
|
+
exit 0
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
# Check if there are any commits yet
|
|
276
|
+
if ! git rev-parse HEAD &>/dev/null; then
|
|
277
|
+
exit 0
|
|
278
|
+
fi
|
|
279
|
+
|
|
280
|
+
CHECKPOINT_DIR=".ai"
|
|
281
|
+
mkdir -p "$CHECKPOINT_DIR"
|
|
282
|
+
|
|
283
|
+
# Save current HEAD as the checkpoint
|
|
284
|
+
git rev-parse HEAD > "$CHECKPOINT_DIR/.last-checkpoint" 2>/dev/null
|
|
285
|
+
|
|
286
|
+
# If there are uncommitted changes, snapshot them without modifying working tree.
|
|
287
|
+
# git stash create returns a SHA but does NOT push to stash list or touch files.
|
|
288
|
+
STASH_SHA=$(git stash create 2>/dev/null)
|
|
289
|
+
if [ -n "$STASH_SHA" ]; then
|
|
290
|
+
echo "$STASH_SHA" > "$CHECKPOINT_DIR/.last-checkpoint-stash"
|
|
291
|
+
echo "{\\"systemMessage\\": \\"Safety snapshot saved. Your current work is preserved. If anything goes wrong, run: npx soloship rollback\\"}"
|
|
292
|
+
else
|
|
293
|
+
rm -f "$CHECKPOINT_DIR/.last-checkpoint-stash"
|
|
294
|
+
echo "{\\"systemMessage\\": \\"Safety snapshot saved. If anything goes wrong, run: npx soloship rollback\\"}"
|
|
295
|
+
fi
|
|
296
|
+
'`;
|
|
297
|
+
}
|
|
298
|
+
function buildSessionStartScript() {
|
|
299
|
+
return `bash -c '
|
|
300
|
+
# Dependency graph injection removed.
|
|
301
|
+
'`;
|
|
302
|
+
}
|
|
303
|
+
function buildPhoneAFriendScript() {
|
|
304
|
+
// Phone-a-friend: warn on git commit/push when staged changes match risk heuristics.
|
|
305
|
+
// All 6 checks use git diff and the filesystem only — no conversation parsing, no AI judgment.
|
|
306
|
+
// Exit 0 always (warn, never block). Warnings via systemMessage JSON.
|
|
307
|
+
return `bash -c '
|
|
308
|
+
COMMAND="$HOOK_TOOL_INPUT"
|
|
309
|
+
|
|
310
|
+
# Only check git commit commands (push has no staged changes to check)
|
|
311
|
+
if ! echo "$COMMAND" | grep -qE "git\\s+commit"; then
|
|
312
|
+
exit 0
|
|
313
|
+
fi
|
|
314
|
+
|
|
315
|
+
WARNINGS=""
|
|
316
|
+
|
|
317
|
+
STAGED=$(git diff --cached --name-only 2>/dev/null)
|
|
318
|
+
if [ -z "$STAGED" ]; then
|
|
319
|
+
exit 0
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
# --- Heuristic 1: Files outside declared source directories ---
|
|
323
|
+
# Detect source dirs from filesystem at runtime
|
|
324
|
+
SRC_PATTERN=""
|
|
325
|
+
for d in src lib app pages components routes services models views controllers public static assets; do
|
|
326
|
+
if [ -d "$d" ]; then
|
|
327
|
+
SRC_PATTERN="$SRC_PATTERN|$d"
|
|
328
|
+
fi
|
|
329
|
+
done
|
|
330
|
+
SRC_PATTERN="\${SRC_PATTERN#|}"
|
|
331
|
+
|
|
332
|
+
if [ -n "$SRC_PATTERN" ]; then
|
|
333
|
+
KNOWN_DIRS="$SRC_PATTERN|tests?|__tests__|spec|__arch__|docs|doc|bin|scripts|dist|build|node_modules|\\.github|\\.claude"
|
|
334
|
+
KNOWN_ROOT="^(package\\.json|tsconfig.*\\.json|README.*|CLAUDE\\.md|AGENTS\\.md|CHANGELOG\\.md|\\.gitignore|\\.eslintrc.*|eslint\\.config.*|prettier.*|vite\\.config.*|next\\.config.*|jest\\.config.*|vitest\\.config.*)$"
|
|
335
|
+
|
|
336
|
+
while IFS= read -r file; do
|
|
337
|
+
[ -z "$file" ] && continue
|
|
338
|
+
DIR_PART=$(echo "$file" | cut -d/ -f1)
|
|
339
|
+
# Skip files in known directories
|
|
340
|
+
if echo "$DIR_PART" | grep -qE "^($KNOWN_DIRS)$"; then
|
|
341
|
+
continue
|
|
342
|
+
fi
|
|
343
|
+
# Skip known root-level files
|
|
344
|
+
BASENAME=$(basename "$file")
|
|
345
|
+
if echo "$BASENAME" | grep -qE "$KNOWN_ROOT"; then
|
|
346
|
+
continue
|
|
347
|
+
fi
|
|
348
|
+
WARNINGS="$WARNINGS - File outside source directories: $file\\n"
|
|
349
|
+
done <<< "$STAGED"
|
|
350
|
+
fi
|
|
351
|
+
|
|
352
|
+
# --- Heuristic 2: Configuration file changes ---
|
|
353
|
+
while IFS= read -r file; do
|
|
354
|
+
[ -z "$file" ] && continue
|
|
355
|
+
if echo "$file" | grep -qiE "\\.(env|env\\..+)$|\\.env$"; then
|
|
356
|
+
WARNINGS="$WARNINGS - Environment file changed: $file\\n"
|
|
357
|
+
elif echo "$file" | grep -qiE "(^|/)(\\.github/|Dockerfile|docker-compose|wrangler\\.toml|vercel\\.json|firebase\\.json|netlify\\.toml|fly\\.toml|\\.circleci/)"; then
|
|
358
|
+
WARNINGS="$WARNINGS - CI/deploy config changed: $file\\n"
|
|
359
|
+
elif echo "$file" | grep -qiE "(package-lock\\.json|yarn\\.lock|pnpm-lock\\.yaml|bun\\.lockb|bun\\.lock|Gemfile\\.lock|Pipfile\\.lock|poetry\\.lock)$"; then
|
|
360
|
+
WARNINGS="$WARNINGS - Lock file changed: $file\\n"
|
|
361
|
+
fi
|
|
362
|
+
done <<< "$STAGED"
|
|
363
|
+
|
|
364
|
+
# --- Heuristic 3: New dependencies added ---
|
|
365
|
+
while IFS= read -r file; do
|
|
366
|
+
[ -z "$file" ] && continue
|
|
367
|
+
if echo "$file" | grep -qE "(package\\.json|requirements\\.txt|Gemfile|Pipfile|pyproject\\.toml|go\\.mod|Cargo\\.toml|pom\\.xml|build\\.gradle)$"; then
|
|
368
|
+
# Check if dependency sections have additions (+ lines in the diff)
|
|
369
|
+
ADDITIONS=$(git diff --cached -- "$file" 2>/dev/null | grep -cE "^\\+.*(dependencies|require|gem |install_requires)" || true)
|
|
370
|
+
if [ "$ADDITIONS" -gt 0 ]; then
|
|
371
|
+
WARNINGS="$WARNINGS - New dependency added (check $file)\\n"
|
|
372
|
+
fi
|
|
373
|
+
fi
|
|
374
|
+
done <<< "$STAGED"
|
|
375
|
+
|
|
376
|
+
# --- Heuristic 4: Auth/migration/env/secret file patterns ---
|
|
377
|
+
while IFS= read -r file; do
|
|
378
|
+
[ -z "$file" ] && continue
|
|
379
|
+
if echo "$file" | grep -qiE "(auth|migration|migrate|secret|credential|security|permission|token|password|session|oauth|jwt|api.?key)"; then
|
|
380
|
+
WARNINGS="$WARNINGS - Security-sensitive file changed: $file\\n"
|
|
381
|
+
fi
|
|
382
|
+
done <<< "$STAGED"
|
|
383
|
+
|
|
384
|
+
# --- Heuristic 5: Large diffs (>300 lines added+removed) ---
|
|
385
|
+
DIFF_STAT=$(git diff --cached --numstat 2>/dev/null | awk "{ added += \\$1; removed += \\$2 } END { print added + removed }")
|
|
386
|
+
if [ -n "$DIFF_STAT" ] && [ "$DIFF_STAT" -gt 300 ] 2>/dev/null; then
|
|
387
|
+
WARNINGS="$WARNINGS - Large change: $DIFF_STAT lines added+removed (threshold: 300)\\n"
|
|
388
|
+
fi
|
|
389
|
+
|
|
390
|
+
# --- Heuristic 6: Removal of validation/sanitization patterns ---
|
|
391
|
+
REMOVED_VALIDATION=$(git diff --cached 2>/dev/null | grep -cE "^-.*(sanitize|validate|escape|parameteriz|prepared.?statement|htmlspecialchars|encodeURI|DOMPurify|csrf|xss|sql.?inject|input.?valid)" || true)
|
|
392
|
+
if [ "$REMOVED_VALIDATION" -gt 0 ]; then
|
|
393
|
+
WARNINGS="$WARNINGS - Validation/sanitization code removed ($REMOVED_VALIDATION lines)\\n"
|
|
394
|
+
fi
|
|
395
|
+
|
|
396
|
+
# --- Output warnings ---
|
|
397
|
+
if [ -n "$WARNINGS" ]; then
|
|
398
|
+
MSG="PHONE A FRIEND — Get a second opinion on these changes before shipping:\\n\\n$WARNINGS\\nAsk a developer you trust, post in a coding community (Reddit, Discord, forum), or use a code review service. Non-obvious changes are where bugs hide."
|
|
399
|
+
ESCAPED=$(printf "%b" "$MSG" | sed "s/\"/\\\\\\\\\"/g" | tr "\\n" " ")
|
|
400
|
+
echo "{\\"systemMessage\\": \\"$ESCAPED\\"}"
|
|
401
|
+
fi
|
|
402
|
+
|
|
403
|
+
exit 0
|
|
404
|
+
'`;
|
|
405
|
+
}
|
|
406
|
+
function buildSecurityScanScript() {
|
|
407
|
+
// Automated security scanning: runs Semgrep on staged files before git commit.
|
|
408
|
+
// Deterministic tool-based scanning, not AI-based — the fox doesn't guard the henhouse.
|
|
409
|
+
// Blocks on critical findings (exit 2), warns on medium (exit 0 + systemMessage).
|
|
410
|
+
// Gracefully skips if semgrep is not installed (with install instructions).
|
|
411
|
+
return `bash -c '
|
|
412
|
+
COMMAND="$HOOK_TOOL_INPUT"
|
|
413
|
+
|
|
414
|
+
# Only check git commit commands
|
|
415
|
+
if ! echo "$COMMAND" | grep -qE "git\\s+commit"; then
|
|
416
|
+
exit 0
|
|
417
|
+
fi
|
|
418
|
+
|
|
419
|
+
STAGED=$(git diff --cached --name-only 2>/dev/null)
|
|
420
|
+
if [ -z "$STAGED" ]; then
|
|
421
|
+
exit 0
|
|
422
|
+
fi
|
|
423
|
+
|
|
424
|
+
# Check if semgrep is available
|
|
425
|
+
if ! command -v semgrep &>/dev/null; then
|
|
426
|
+
if [ -f ".semgrep.yml" ] || [ -d ".semgrep" ]; then
|
|
427
|
+
echo "{\\"systemMessage\\": \\"Security scan skipped: semgrep not installed. Install with: pip install semgrep (or pipx install semgrep)\\"}"
|
|
428
|
+
fi
|
|
429
|
+
exit 0
|
|
430
|
+
fi
|
|
431
|
+
|
|
432
|
+
# Only scan source files that are staged
|
|
433
|
+
SCAN_FILES=""
|
|
434
|
+
while IFS= read -r file; do
|
|
435
|
+
[ -z "$file" ] && continue
|
|
436
|
+
if echo "$file" | grep -qE "\\.(ts|tsx|js|jsx|py|rb|go|java|php|rs)$"; then
|
|
437
|
+
if [ -f "$file" ]; then
|
|
438
|
+
SCAN_FILES="$SCAN_FILES $file"
|
|
439
|
+
fi
|
|
440
|
+
fi
|
|
441
|
+
done <<< "$STAGED"
|
|
442
|
+
|
|
443
|
+
if [ -z "$SCAN_FILES" ]; then
|
|
444
|
+
exit 0
|
|
445
|
+
fi
|
|
446
|
+
|
|
447
|
+
# Use project config if available, otherwise OWASP rules
|
|
448
|
+
SEMGREP_CONFIG=".semgrep.yml"
|
|
449
|
+
if [ ! -f "$SEMGREP_CONFIG" ] && [ ! -d ".semgrep" ]; then
|
|
450
|
+
SEMGREP_CONFIG="p/owasp-top-ten"
|
|
451
|
+
fi
|
|
452
|
+
|
|
453
|
+
# Run scan, capture output
|
|
454
|
+
RESULTS=$(semgrep --config "$SEMGREP_CONFIG" --json $SCAN_FILES 2>/dev/null || true)
|
|
455
|
+
|
|
456
|
+
if [ -z "$RESULTS" ]; then
|
|
457
|
+
exit 0
|
|
458
|
+
fi
|
|
459
|
+
|
|
460
|
+
# Count findings by severity using grep (no python3 dependency)
|
|
461
|
+
CRITICAL=$(echo "$RESULTS" | grep -cE "\\"severity\\"[[:space:]]*:[[:space:]]*\\"ERROR\\"" 2>/dev/null || echo "0")
|
|
462
|
+
MEDIUM=$(echo "$RESULTS" | grep -cE "\\"severity\\"[[:space:]]*:[[:space:]]*\\"WARNING\\"" 2>/dev/null || echo "0")
|
|
463
|
+
|
|
464
|
+
# Block on critical findings
|
|
465
|
+
if [ "$CRITICAL" -gt 0 ]; then
|
|
466
|
+
echo "BLOCKED: Semgrep found $CRITICAL critical security finding(s). Run: semgrep --config $SEMGREP_CONFIG to see details." >&2
|
|
467
|
+
exit 2
|
|
468
|
+
fi
|
|
469
|
+
|
|
470
|
+
# Warn on medium findings
|
|
471
|
+
if [ "$MEDIUM" -gt 0 ]; then
|
|
472
|
+
echo "{\\"systemMessage\\": \\"Security scan: $MEDIUM medium-severity finding(s). Run semgrep for details. Consider fixing before shipping.\\"}"
|
|
473
|
+
fi
|
|
474
|
+
|
|
475
|
+
exit 0
|
|
476
|
+
'`;
|
|
477
|
+
}
|
package/dist/init.d.ts
ADDED
package/dist/init.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { input } from "@inquirer/prompts";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { detectProject } from "./detect.js";
|
|
5
|
+
import { scaffoldDocs } from "./scaffold.js";
|
|
6
|
+
import { installHooks } from "./hooks.js";
|
|
7
|
+
import { installRules } from "./rules.js";
|
|
8
|
+
import { installCi } from "./ci.js";
|
|
9
|
+
export async function runInit(options) {
|
|
10
|
+
const root = process.cwd();
|
|
11
|
+
// Step 1: Detect project
|
|
12
|
+
console.log(chalk.blue("Detecting project..."));
|
|
13
|
+
const detected = detectProject(root);
|
|
14
|
+
const stack = detected.stack;
|
|
15
|
+
const existingDocs = detected.existingDocs;
|
|
16
|
+
if (stack.language !== "unknown") {
|
|
17
|
+
console.log(` Stack: ${chalk.cyan(stack.language)}` +
|
|
18
|
+
(stack.framework ? ` + ${chalk.cyan(stack.framework)}` : ""));
|
|
19
|
+
console.log(` Package manager: ${chalk.cyan(stack.packageManager)}`);
|
|
20
|
+
}
|
|
21
|
+
if (existingDocs.hasClaudeMd) {
|
|
22
|
+
console.log(` ${chalk.yellow("CLAUDE.md already exists")} — will not overwrite`);
|
|
23
|
+
}
|
|
24
|
+
console.log("");
|
|
25
|
+
// Step 2: Gather project info
|
|
26
|
+
let projectName = detected.name;
|
|
27
|
+
let projectDescription;
|
|
28
|
+
if (!options.skipPrompts) {
|
|
29
|
+
if (!projectName) {
|
|
30
|
+
projectName = await input({
|
|
31
|
+
message: "Project name:",
|
|
32
|
+
default: root.split("/").pop(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(` Project: ${chalk.bold(projectName)}`);
|
|
37
|
+
}
|
|
38
|
+
projectDescription = await input({
|
|
39
|
+
message: "One sentence — what does this project do?",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const projectInfo = {
|
|
43
|
+
name: projectName || root.split("/").pop() || "my-project",
|
|
44
|
+
description: projectDescription || "",
|
|
45
|
+
stack,
|
|
46
|
+
hasGit: detected.hasGit || false,
|
|
47
|
+
hasClaude: detected.hasClaude || false,
|
|
48
|
+
existingDocs,
|
|
49
|
+
};
|
|
50
|
+
// Step 3: Scaffold documentation infrastructure
|
|
51
|
+
console.log("");
|
|
52
|
+
console.log(chalk.blue("Creating documentation infrastructure..."));
|
|
53
|
+
const scaffoldResults = await scaffoldDocs(root, projectInfo);
|
|
54
|
+
for (const result of scaffoldResults) {
|
|
55
|
+
const icon = result.action === "created" ? chalk.green("+") : chalk.yellow("~");
|
|
56
|
+
console.log(` ${icon} ${result.path} ${chalk.dim(`(${result.action})`)}`);
|
|
57
|
+
}
|
|
58
|
+
// Step 4: Install Claude Code hooks
|
|
59
|
+
console.log("");
|
|
60
|
+
console.log(chalk.blue("Configuring Claude Code hooks..."));
|
|
61
|
+
const hookResults = await installHooks(root, projectInfo);
|
|
62
|
+
for (const result of hookResults) {
|
|
63
|
+
console.log(` ${chalk.green("+")} ${result}`);
|
|
64
|
+
}
|
|
65
|
+
// Step 5: Install rules
|
|
66
|
+
console.log("");
|
|
67
|
+
console.log(chalk.blue("Installing workflow rules..."));
|
|
68
|
+
const ruleResults = await installRules(root);
|
|
69
|
+
for (const result of ruleResults) {
|
|
70
|
+
console.log(` ${chalk.green("+")} ${result}`);
|
|
71
|
+
}
|
|
72
|
+
// Step 6: Install CI + architecture fitness functions
|
|
73
|
+
console.log("");
|
|
74
|
+
console.log(chalk.blue("Setting up CI..."));
|
|
75
|
+
const ciResults = await installCi(root, projectInfo);
|
|
76
|
+
for (const result of ciResults) {
|
|
77
|
+
console.log(` ${chalk.green("+")} ${result}`);
|
|
78
|
+
}
|
|
79
|
+
// Post-install notes
|
|
80
|
+
const notes = [];
|
|
81
|
+
try {
|
|
82
|
+
execSync("command -v semgrep", { stdio: "ignore" });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
notes.push(`Semgrep not found. Security scanning hooks will skip until installed: ${chalk.cyan("pipx install semgrep")}`);
|
|
86
|
+
}
|
|
87
|
+
if (notes.length > 0) {
|
|
88
|
+
console.log("");
|
|
89
|
+
console.log(chalk.yellow("Notes:"));
|
|
90
|
+
for (const note of notes) {
|
|
91
|
+
console.log(` ${chalk.dim("→")} ${note}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soloship dependency manifest.
|
|
3
|
+
*
|
|
4
|
+
* Declares the companion plugins, MCP servers, user-scope skills, and global
|
|
5
|
+
* hooks that Soloship either REQUIRES (its skills will break without them) or
|
|
6
|
+
* RECOMMENDS (non-coder workflow is materially improved by having them).
|
|
7
|
+
*
|
|
8
|
+
* The `doctor` command audits this list against the user's actual
|
|
9
|
+
* `~/.claude/` environment and reports what's missing with install commands.
|
|
10
|
+
*
|
|
11
|
+
* Edit this file when Soloship adds or drops companion dependencies. The
|
|
12
|
+
* manifest is the single source of truth for dependency information — do not
|
|
13
|
+
* hard-code checks elsewhere.
|
|
14
|
+
*/
|
|
15
|
+
export type Severity = "required" | "recommended";
|
|
16
|
+
export interface PluginDep {
|
|
17
|
+
/** Plugin identifier as it appears in ~/.claude/settings.json enabledPlugins */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Marketplace the plugin ships from (the suffix after @ in enabledPlugins) */
|
|
20
|
+
source: string;
|
|
21
|
+
severity: Severity;
|
|
22
|
+
/** One-sentence purpose for the doctor output */
|
|
23
|
+
purpose: string;
|
|
24
|
+
/** Which Soloship skills route to this plugin */
|
|
25
|
+
usedBy: string[];
|
|
26
|
+
/** Install guidance shown when missing */
|
|
27
|
+
install: string;
|
|
28
|
+
}
|
|
29
|
+
export interface McpServerDep {
|
|
30
|
+
/** Server name as it appears in `claude mcp list` */
|
|
31
|
+
name: string;
|
|
32
|
+
severity: Severity;
|
|
33
|
+
purpose: string;
|
|
34
|
+
/** Install command (or template — use <VAULT_PATH>, <PROJECT_ROOT>, etc.) */
|
|
35
|
+
install: string;
|
|
36
|
+
/** Notes shown alongside install command */
|
|
37
|
+
notes?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface SkillDep {
|
|
40
|
+
/** Directory name under ~/.claude/skills/ */
|
|
41
|
+
name: string;
|
|
42
|
+
severity: Severity;
|
|
43
|
+
purpose: string;
|
|
44
|
+
install: string;
|
|
45
|
+
}
|
|
46
|
+
export interface HookDep {
|
|
47
|
+
/** Hook event name (SessionStart, PreToolUse, Stop, etc.) */
|
|
48
|
+
event: string;
|
|
49
|
+
/** Matcher value, or null for matcher-less hooks */
|
|
50
|
+
matcher: string | null;
|
|
51
|
+
/** Substring that identifies this hook's command in settings.json */
|
|
52
|
+
commandContains: string;
|
|
53
|
+
severity: Severity;
|
|
54
|
+
purpose: string;
|
|
55
|
+
install: string;
|
|
56
|
+
}
|
|
57
|
+
export interface DependencyManifest {
|
|
58
|
+
plugins: PluginDep[];
|
|
59
|
+
mcpServers: McpServerDep[];
|
|
60
|
+
skills: SkillDep[];
|
|
61
|
+
hooks: HookDep[];
|
|
62
|
+
}
|
|
63
|
+
export declare const SOLOSHIP_MANIFEST: DependencyManifest;
|