refactor: CI optimization, logging improvements, and codebase formatting (#456)
* fix(db): remove unique constraint on folder to support multi-channel agents * ci: implement automated skill drift detection and self-healing PRs * fix: align registration logic with Gavriel's feedback and fix build/test issues from Daniel Mi * style: conform to prettier standards for CI validation * test: fix branch naming inconsistency in CI (master vs main) * fix(ci): robust module resolution by removing file extensions in scripts * refactor(ci): simplify skill validation by removing redundant combination tests * style: conform skills-engine to prettier, unify logging in index.ts and cleanup unused imports * refactor: extract multi-channel DB changes to separate branch Move channel column, folder suffix logic, and related migrations to feat/multi-channel-db-v2 for independent review. This PR now contains only CI/CD optimizations, Prettier formatting, and logging improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
name: Test
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -14,5 +14,12 @@ jobs:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
- run: npx vitest run
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Tests
|
||||
run: npx vitest run
|
||||
102
.github/workflows/skill-drift.yml
vendored
Normal file
102
.github/workflows/skill-drift.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Skill Drift Detection
|
||||
|
||||
# Runs after every push to main that touches source files.
|
||||
# Validates every skill can still be cleanly applied, type-checked, and tested.
|
||||
# If a skill drifts, attempts auto-fix via three-way merge of modify/ files,
|
||||
# then opens a PR with the result (auto-fixed or with conflict markers).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'container/**'
|
||||
- 'package.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Step 1: Check all skills against current main ─────────────────────
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
drifted: ${{ steps.check.outputs.drifted }}
|
||||
drifted_skills: ${{ steps.check.outputs.drifted_skills }}
|
||||
results: ${{ steps.check.outputs.results }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
- name: Validate all skills against main
|
||||
id: check
|
||||
run: npx tsx scripts/validate-all-skills.ts
|
||||
continue-on-error: true
|
||||
|
||||
# ── Step 2: Auto-fix and create PR ────────────────────────────────────
|
||||
fix-drift:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.drifted == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
- name: Attempt auto-fix via three-way merge
|
||||
id: fix
|
||||
run: |
|
||||
SKILLS=$(echo '${{ needs.validate.outputs.drifted_skills }}' | jq -r '.[]')
|
||||
npx tsx scripts/fix-skill-drift.ts $SKILLS
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
branch: ci/fix-skill-drift
|
||||
delete-branch: true
|
||||
title: 'fix(skills): auto-update drifted skills'
|
||||
body: |
|
||||
## Skill Drift Detected
|
||||
|
||||
A push to `main` (${{ github.sha }}) changed source files that caused
|
||||
the following skills to fail validation:
|
||||
|
||||
**Drifted:** ${{ needs.validate.outputs.drifted_skills }}
|
||||
|
||||
### Auto-fix results
|
||||
|
||||
${{ steps.fix.outputs.summary }}
|
||||
|
||||
### What to do
|
||||
|
||||
1. Review the changes to `.claude/skills/*/modify/` files
|
||||
2. If there are conflict markers (`<<<<<<<`), resolve them
|
||||
3. CI will run typecheck + tests on this PR automatically
|
||||
4. Merge when green
|
||||
|
||||
---
|
||||
*Auto-generated by [skill-drift CI](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*
|
||||
labels: skill-drift,automated
|
||||
commit-message: 'fix(skills): auto-update drifted skill modify/ files'
|
||||
151
.github/workflows/skill-pr.yml
vendored
Normal file
151
.github/workflows/skill-pr.yml
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
name: Skill PR Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.claude/skills/**'
|
||||
- 'skills-engine/**'
|
||||
|
||||
jobs:
|
||||
# ── Job 1: Policy gate ────────────────────────────────────────────────
|
||||
# Block PRs that add NEW skill files while also modifying source code.
|
||||
# Skill PRs should contain instructions for Claude, not raw source edits.
|
||||
policy-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for mixed skill + source changes
|
||||
run: |
|
||||
ADDED_SKILLS=$(git diff --name-only --diff-filter=A origin/main...HEAD \
|
||||
| grep '^\\.claude/skills/' || true)
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD)
|
||||
SOURCE=$(echo "$CHANGED" \
|
||||
| grep -E '^src/|^container/|^package\.json|^package-lock\.json' || true)
|
||||
|
||||
if [ -n "$ADDED_SKILLS" ] && [ -n "$SOURCE" ]; then
|
||||
echo "::error::PRs that add new skills should not modify source files."
|
||||
echo ""
|
||||
echo "New skill files:"
|
||||
echo "$ADDED_SKILLS"
|
||||
echo ""
|
||||
echo "Source files:"
|
||||
echo "$SOURCE"
|
||||
echo ""
|
||||
echo "Please split into separate PRs. See CONTRIBUTING.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Comment on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `This PR adds a skill while also modifying source code. A skill PR should not change source files—the skill should contain **instructions** for Claude to follow.
|
||||
|
||||
If you're fixing a bug or simplifying code, please submit that as a separate PR.
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for details.`
|
||||
})
|
||||
|
||||
# ── Job 2: Detect which skills changed ────────────────────────────────
|
||||
detect-changed:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skills: ${{ steps.detect.outputs.skills }}
|
||||
has_skills: ${{ steps.detect.outputs.has_skills }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed skills
|
||||
id: detect
|
||||
run: |
|
||||
CHANGED_SKILLS=$(git diff --name-only origin/main...HEAD \
|
||||
| grep '^\\.claude/skills/' \
|
||||
| sed 's|^\.claude/skills/||' \
|
||||
| cut -d/ -f1 \
|
||||
| sort -u \
|
||||
| jq -R . | jq -s .)
|
||||
echo "skills=$CHANGED_SKILLS" >> "$GITHUB_OUTPUT"
|
||||
if [ "$CHANGED_SKILLS" = "[]" ]; then
|
||||
echo "has_skills=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_skills=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Changed skills: $CHANGED_SKILLS"
|
||||
|
||||
# ── Job 3: Validate each changed skill in isolation ───────────────────
|
||||
validate-skills:
|
||||
needs: detect-changed
|
||||
if: needs.detect-changed.outputs.has_skills == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
skill: ${{ fromJson(needs.detect-changed.outputs.skills) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
- name: Initialize skills system
|
||||
run: >-
|
||||
npx tsx -e
|
||||
"import { initNanoclawDir } from './skills-engine/index'; initNanoclawDir();"
|
||||
|
||||
- name: Apply skill
|
||||
run: npx tsx scripts/apply-skill.ts ".claude/skills/${{ matrix.skill }}"
|
||||
|
||||
- name: Typecheck after apply
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run skill tests
|
||||
run: |
|
||||
TEST_CMD=$(npx tsx -e "
|
||||
import { parse } from 'yaml';
|
||||
import fs from 'fs';
|
||||
const m = parse(fs.readFileSync('.claude/skills/${{ matrix.skill }}/manifest.yaml', 'utf-8'));
|
||||
if (m.test) console.log(m.test);
|
||||
")
|
||||
if [ -n "$TEST_CMD" ]; then
|
||||
echo "Running: $TEST_CMD"
|
||||
eval "$TEST_CMD"
|
||||
else
|
||||
echo "No test command defined, skipping"
|
||||
fi
|
||||
|
||||
# ── Summary gate for branch protection ────────────────────────────────
|
||||
skill-validation-summary:
|
||||
needs:
|
||||
- policy-check
|
||||
- detect-changed
|
||||
- validate-skills
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check results
|
||||
run: |
|
||||
echo "policy-check: ${{ needs.policy-check.result }}"
|
||||
echo "validate-skills: ${{ needs.validate-skills.result }}"
|
||||
|
||||
if [ "${{ needs.policy-check.result }}" = "failure" ]; then
|
||||
echo "::error::Policy check failed"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ needs.validate-skills.result }}" = "failure" ]; then
|
||||
echo "::error::Skill validation failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All skill checks passed"
|
||||
84
.github/workflows/skill-tests.yml
vendored
84
.github/workflows/skill-tests.yml
vendored
@@ -1,84 +0,0 @@
|
||||
name: Skill Combination Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'skills-engine/**'
|
||||
- '.claude/skills/**'
|
||||
- 'src/**'
|
||||
|
||||
jobs:
|
||||
generate-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
has_entries: ${{ steps.matrix.outputs.has_entries }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Generate overlap matrix
|
||||
id: matrix
|
||||
run: |
|
||||
MATRIX=$(npx tsx scripts/generate-ci-matrix.ts)
|
||||
{
|
||||
echo "matrix<<MATRIX_EOF"
|
||||
echo "$MATRIX"
|
||||
echo "MATRIX_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ "$MATRIX" = "[]" ]; then
|
||||
echo "has_entries=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_entries=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test-combinations:
|
||||
needs: generate-matrix
|
||||
if: needs.generate-matrix.outputs.has_entries == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
entry: ${{ fromJson(needs.generate-matrix.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
- name: Initialize nanoclaw dir
|
||||
run: npx tsx -e "import { initNanoclawDir } from './skills-engine/index.js'; initNanoclawDir();"
|
||||
|
||||
- name: Apply skills in sequence
|
||||
run: |
|
||||
for skill in $(echo '${{ toJson(matrix.entry.skills) }}' | jq -r '.[]'); do
|
||||
echo "Applying skill: $skill"
|
||||
npx tsx scripts/apply-skill.ts ".claude/skills/$skill"
|
||||
done
|
||||
|
||||
- name: Run skill tests
|
||||
run: npx vitest run --config vitest.skills.config.ts
|
||||
|
||||
skill-tests-summary:
|
||||
needs: [generate-matrix, test-combinations]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Report result
|
||||
run: |
|
||||
if [ "${{ needs.generate-matrix.outputs.has_entries }}" = "false" ]; then
|
||||
echo "No overlapping skills found. Skipped combination tests."
|
||||
exit 0
|
||||
fi
|
||||
if [ "${{ needs.test-combinations.result }}" = "success" ]; then
|
||||
echo "All skill combination tests passed."
|
||||
else
|
||||
echo "Some skill combination tests failed."
|
||||
exit 1
|
||||
fi
|
||||
52
.github/workflows/skills-only.yml
vendored
52
.github/workflows/skills-only.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: Skill PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for mixed skill + source changes
|
||||
run: |
|
||||
# Check if PR adds new skill files (not just modifies existing)
|
||||
ADDED_SKILLS=$(git diff --name-only --diff-filter=A origin/main...HEAD | grep '^\.claude/skills/' || true)
|
||||
|
||||
# Check if PR touches source
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD)
|
||||
SOURCE=$(echo "$CHANGED" | grep -E '^src/|^container/|^package\.json|^package-lock\.json' || true)
|
||||
|
||||
# Block if new skills are added AND source is modified
|
||||
if [ -n "$ADDED_SKILLS" ] && [ -n "$SOURCE" ]; then
|
||||
echo "❌ PRs that add skills should not modify source files."
|
||||
echo ""
|
||||
echo "New skill files:"
|
||||
echo "$ADDED_SKILLS"
|
||||
echo ""
|
||||
echo "Source files:"
|
||||
echo "$SOURCE"
|
||||
echo ""
|
||||
echo "Please read CONTRIBUTING.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Comment on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `This PR adds a skill while also modifying source code. A skill PR should not change source files—the skill should contain **instructions** for Claude to follow. See \`/add-telegram\` for an example.
|
||||
|
||||
If you're fixing a bug or simplifying code, please submit that as a separate PR.
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for details.`
|
||||
})
|
||||
@@ -1,8 +1,18 @@
|
||||
import { applySkill } from '../skills-engine/apply.js';
|
||||
import { initNanoclawDir } from '../skills-engine/init.js';
|
||||
|
||||
const skillDir = process.argv[2];
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Handle --init flag: initialize .nanoclaw/ directory and exit
|
||||
if (args.includes('--init')) {
|
||||
initNanoclawDir();
|
||||
console.log(JSON.stringify({ success: true, action: 'init' }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const skillDir = args[0];
|
||||
if (!skillDir) {
|
||||
console.error('Usage: tsx scripts/apply-skill.ts <skill-dir>');
|
||||
console.error('Usage: tsx scripts/apply-skill.ts [--init] <skill-dir>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
266
scripts/fix-skill-drift.ts
Normal file
266
scripts/fix-skill-drift.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Auto-fix drifted skills by three-way merging their modify/ files.
|
||||
*
|
||||
* For each drifted skill's `modifies` entry:
|
||||
* 1. Find the commit where the skill's modify/ copy was last updated
|
||||
* 2. Retrieve the source file at that commit (old base)
|
||||
* 3. git merge-file <modify/file> <old_base> <current_main>
|
||||
* - Clean merge → modify/ file is auto-updated
|
||||
* - Conflicts → conflict markers left in place for human/Claude review
|
||||
*
|
||||
* The calling workflow should commit the resulting changes and create a PR.
|
||||
*
|
||||
* Sets GitHub Actions outputs:
|
||||
* has_conflicts — "true" | "false"
|
||||
* fixed_count — number of auto-fixed files
|
||||
* conflict_count — number of files with unresolved conflict markers
|
||||
* summary — human-readable summary for PR body
|
||||
*
|
||||
* Usage: npx tsx scripts/fix-skill-drift.ts add-telegram add-discord
|
||||
*/
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { parse } from 'yaml';
|
||||
import type { SkillManifest } from '../skills-engine/types.js';
|
||||
|
||||
interface FixResult {
|
||||
skill: string;
|
||||
file: string;
|
||||
status: 'auto-fixed' | 'conflict' | 'skipped' | 'error';
|
||||
conflicts?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function readManifest(skillDir: string): SkillManifest {
|
||||
const manifestPath = path.join(skillDir, 'manifest.yaml');
|
||||
return parse(fs.readFileSync(manifestPath, 'utf-8')) as SkillManifest;
|
||||
}
|
||||
|
||||
function fixSkill(skillName: string, projectRoot: string): FixResult[] {
|
||||
const skillDir = path.join(projectRoot, '.claude', 'skills', skillName);
|
||||
const manifest = readManifest(skillDir);
|
||||
const results: FixResult[] = [];
|
||||
|
||||
for (const relPath of manifest.modifies) {
|
||||
const modifyPath = path.join(skillDir, 'modify', relPath);
|
||||
const currentPath = path.join(projectRoot, relPath);
|
||||
|
||||
if (!fs.existsSync(modifyPath)) {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'modify/ file not found',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'source file not found on main',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find when the skill's modify file was last changed
|
||||
let lastCommit: string;
|
||||
try {
|
||||
lastCommit = execSync(`git log -1 --format=%H -- "${modifyPath}"`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'no git history for modify file',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastCommit) {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'no commits found for modify file',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the source file at that commit (the old base the skill was written against)
|
||||
const tmpOldBase = path.join(
|
||||
os.tmpdir(),
|
||||
`nanoclaw-drift-base-${crypto.randomUUID()}`,
|
||||
);
|
||||
try {
|
||||
const oldBase = execSync(`git show "${lastCommit}:${relPath}"`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
fs.writeFileSync(tmpOldBase, oldBase);
|
||||
} catch {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: `source file not found at commit ${lastCommit.slice(0, 7)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// If old base == current main, the source hasn't changed since the skill was updated.
|
||||
// The skill is already in sync for this file.
|
||||
const currentContent = fs.readFileSync(currentPath, 'utf-8');
|
||||
const oldBaseContent = fs.readFileSync(tmpOldBase, 'utf-8');
|
||||
if (oldBaseContent === currentContent) {
|
||||
fs.unlinkSync(tmpOldBase);
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'source unchanged since skill update',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Three-way merge: modify/file ← old_base → current_main
|
||||
// git merge-file modifies first argument in-place
|
||||
try {
|
||||
execFileSync('git', ['merge-file', modifyPath, tmpOldBase, currentPath], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
results.push({ skill: skillName, file: relPath, status: 'auto-fixed' });
|
||||
} catch (err: any) {
|
||||
const exitCode = err.status ?? -1;
|
||||
if (exitCode > 0) {
|
||||
// Positive exit code = number of conflicts, file has markers
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'conflict',
|
||||
conflicts: exitCode,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'error',
|
||||
reason: err.message,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(tmpOldBase);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function setOutput(key: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (!outputFile) return;
|
||||
|
||||
if (value.includes('\n')) {
|
||||
const delimiter = `ghadelim_${Date.now()}`;
|
||||
fs.appendFileSync(
|
||||
outputFile,
|
||||
`${key}<<${delimiter}\n${value}\n${delimiter}\n`,
|
||||
);
|
||||
} else {
|
||||
fs.appendFileSync(outputFile, `${key}=${value}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const skillNames = process.argv.slice(2);
|
||||
|
||||
if (skillNames.length === 0) {
|
||||
console.error(
|
||||
'Usage: npx tsx scripts/fix-skill-drift.ts <skill1> [skill2] ...',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Attempting auto-fix for: ${skillNames.join(', ')}\n`);
|
||||
|
||||
const allResults: FixResult[] = [];
|
||||
|
||||
for (const skillName of skillNames) {
|
||||
console.log(`--- ${skillName} ---`);
|
||||
const results = fixSkill(skillName, projectRoot);
|
||||
allResults.push(...results);
|
||||
|
||||
for (const r of results) {
|
||||
const icon =
|
||||
r.status === 'auto-fixed'
|
||||
? 'FIXED'
|
||||
: r.status === 'conflict'
|
||||
? `CONFLICT (${r.conflicts})`
|
||||
: r.status === 'skipped'
|
||||
? 'SKIP'
|
||||
: 'ERROR';
|
||||
const detail = r.reason ? ` -- ${r.reason}` : '';
|
||||
console.log(` ${icon} ${r.file}${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const fixed = allResults.filter((r) => r.status === 'auto-fixed');
|
||||
const conflicts = allResults.filter((r) => r.status === 'conflict');
|
||||
const skipped = allResults.filter((r) => r.status === 'skipped');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(` Auto-fixed: ${fixed.length}`);
|
||||
console.log(` Conflicts: ${conflicts.length}`);
|
||||
console.log(` Skipped: ${skipped.length}`);
|
||||
|
||||
// Build markdown summary for PR body
|
||||
const summaryLines: string[] = [];
|
||||
for (const skillName of skillNames) {
|
||||
const skillResults = allResults.filter((r) => r.skill === skillName);
|
||||
const fixedFiles = skillResults.filter((r) => r.status === 'auto-fixed');
|
||||
const conflictFiles = skillResults.filter((r) => r.status === 'conflict');
|
||||
|
||||
summaryLines.push(`### ${skillName}`);
|
||||
if (fixedFiles.length > 0) {
|
||||
summaryLines.push(
|
||||
`Auto-fixed: ${fixedFiles.map((r) => `\`${r.file}\``).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (conflictFiles.length > 0) {
|
||||
summaryLines.push(
|
||||
`Needs manual resolution: ${conflictFiles.map((r) => `\`${r.file}\``).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (fixedFiles.length === 0 && conflictFiles.length === 0) {
|
||||
summaryLines.push('No modify/ files needed updating.');
|
||||
}
|
||||
summaryLines.push('');
|
||||
}
|
||||
|
||||
// GitHub outputs
|
||||
setOutput('has_conflicts', conflicts.length > 0 ? 'true' : 'false');
|
||||
setOutput('fixed_count', String(fixed.length));
|
||||
setOutput('conflict_count', String(conflicts.length));
|
||||
setOutput('summary', summaryLines.join('\n'));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parse } from 'yaml';
|
||||
import { SkillManifest } from '../skills-engine/types.js';
|
||||
|
||||
export interface MatrixEntry {
|
||||
skills: string[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SkillOverlapInfo {
|
||||
name: string;
|
||||
modifies: string[];
|
||||
npmDependencies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract overlap-relevant info from a parsed manifest.
|
||||
* @param dirName - The skill's directory name (e.g. 'add-discord'), used in matrix
|
||||
* entries so CI/scripts can locate the skill package on disk.
|
||||
*/
|
||||
export function extractOverlapInfo(manifest: SkillManifest, dirName: string): SkillOverlapInfo {
|
||||
const npmDeps = manifest.structured?.npm_dependencies
|
||||
? Object.keys(manifest.structured.npm_dependencies)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: dirName,
|
||||
modifies: manifest.modifies ?? [],
|
||||
npmDependencies: npmDeps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute overlap matrix from a list of skill overlap infos.
|
||||
* Two skills overlap if they share any `modifies` entry or both declare
|
||||
* `structured.npm_dependencies` for the same package.
|
||||
*/
|
||||
export function computeOverlapMatrix(skills: SkillOverlapInfo[]): MatrixEntry[] {
|
||||
const entries: MatrixEntry[] = [];
|
||||
|
||||
for (let i = 0; i < skills.length; i++) {
|
||||
for (let j = i + 1; j < skills.length; j++) {
|
||||
const a = skills[i];
|
||||
const b = skills[j];
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Check shared modifies entries
|
||||
const sharedModifies = a.modifies.filter((m) => b.modifies.includes(m));
|
||||
if (sharedModifies.length > 0) {
|
||||
reasons.push(`shared modifies: ${sharedModifies.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check shared npm_dependencies packages
|
||||
const sharedNpm = a.npmDependencies.filter((pkg) =>
|
||||
b.npmDependencies.includes(pkg),
|
||||
);
|
||||
if (sharedNpm.length > 0) {
|
||||
reasons.push(`shared npm packages: ${sharedNpm.join(', ')}`);
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
entries.push({
|
||||
skills: [a.name, b.name],
|
||||
reason: reasons.join('; '),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all skill manifests from a skills directory (e.g. .claude/skills/).
|
||||
* Each subdirectory should contain a manifest.yaml.
|
||||
* Returns both the parsed manifest and the directory name.
|
||||
*/
|
||||
export function readAllManifests(skillsDir: string): { manifest: SkillManifest; dirName: string }[] {
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: { manifest: SkillManifest; dirName: string }[] = [];
|
||||
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const manifestPath = path.join(skillsDir, entry.name, 'manifest.yaml');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
const content = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = parse(content) as SkillManifest;
|
||||
results.push({ manifest, dirName: entry.name });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full CI matrix from a skills directory.
|
||||
*/
|
||||
export function generateMatrix(skillsDir: string): MatrixEntry[] {
|
||||
const entries = readAllManifests(skillsDir);
|
||||
const overlapInfos = entries.map((e) => extractOverlapInfo(e.manifest, e.dirName));
|
||||
return computeOverlapMatrix(overlapInfos);
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.url.replace('file://', ''))) {
|
||||
const projectRoot = process.cwd();
|
||||
const skillsDir = path.join(projectRoot, '.claude', 'skills');
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
console.log(JSON.stringify(matrix, null, 2));
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { generateMatrix, MatrixEntry } from './generate-ci-matrix.js';
|
||||
|
||||
interface TestResult {
|
||||
entry: MatrixEntry;
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function copyDirRecursive(src: string, dest: string, exclude: string[] = []): void {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (exclude.includes(entry.name)) continue;
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyDirRecursive(srcPath, destPath, exclude);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runMatrixEntry(
|
||||
projectRoot: string,
|
||||
entry: MatrixEntry,
|
||||
): Promise<TestResult> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-ci-'));
|
||||
|
||||
try {
|
||||
// Copy project to temp dir (exclude heavy/irrelevant dirs)
|
||||
copyDirRecursive(projectRoot, tmpDir, [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'data',
|
||||
'store',
|
||||
'logs',
|
||||
'.nanoclaw',
|
||||
]);
|
||||
|
||||
// Install dependencies
|
||||
execSync('npm install --ignore-scripts', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
// Initialize nanoclaw dir
|
||||
execSync('npx tsx -e "import { initNanoclawDir } from \'./skills-engine/index.js\'; initNanoclawDir();"', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Apply each skill in sequence
|
||||
for (const skillName of entry.skills) {
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', skillName);
|
||||
if (!fs.existsSync(skillDir)) {
|
||||
return {
|
||||
entry,
|
||||
passed: false,
|
||||
error: `Skill directory not found: ${skillName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = execSync(
|
||||
`npx tsx scripts/apply-skill.ts "${skillDir}"`,
|
||||
{ cwd: tmpDir, stdio: 'pipe', timeout: 120_000 },
|
||||
);
|
||||
const parsed = JSON.parse(result.toString());
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
entry,
|
||||
passed: false,
|
||||
error: `Failed to apply skill ${skillName}: ${parsed.error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Run all skill tests
|
||||
execSync('npx vitest run --config vitest.skills.config.ts', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
return { entry, passed: true };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
entry,
|
||||
passed: false,
|
||||
error: err.message || String(err),
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const skillsDir = path.join(projectRoot, '.claude', 'skills');
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
|
||||
if (matrix.length === 0) {
|
||||
console.log('No overlapping skills found. Nothing to test.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${matrix.length} overlapping skill combination(s):\n`);
|
||||
for (const entry of matrix) {
|
||||
console.log(` [${entry.skills.join(', ')}] — ${entry.reason}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const results: TestResult[] = [];
|
||||
for (const entry of matrix) {
|
||||
console.log(`Testing: [${entry.skills.join(', ')}]...`);
|
||||
const result = await runMatrixEntry(projectRoot, entry);
|
||||
results.push(result);
|
||||
console.log(` ${result.passed ? 'PASS' : 'FAIL'}${result.error ? ` — ${result.error}` : ''}`);
|
||||
}
|
||||
|
||||
console.log('\n--- Summary ---');
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.filter((r) => !r.passed).length;
|
||||
console.log(`${passed} passed, ${failed} failed out of ${results.length} combination(s)`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -43,9 +43,7 @@ const results: MigrationResult[] = [];
|
||||
const migrationsDir = path.join(newCorePath, 'migrations');
|
||||
|
||||
if (!fs.existsSync(migrationsDir)) {
|
||||
console.log(
|
||||
JSON.stringify({ migrationsRun: 0, results: [] }, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify({ migrationsRun: 0, results: [] }, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -84,18 +82,13 @@ for (const version of migrationVersions) {
|
||||
});
|
||||
results.push({ version, success: true });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
results.push({ version, success: false, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ migrationsRun: results.length, results },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
JSON.stringify({ migrationsRun: results.length, results }, null, 2),
|
||||
);
|
||||
|
||||
// Exit with error if any migration failed
|
||||
|
||||
@@ -13,7 +13,9 @@ async function main() {
|
||||
|
||||
if (result.customPatchWarning) {
|
||||
console.warn(`\nWarning: ${result.customPatchWarning}`);
|
||||
console.warn('To proceed, remove the custom_patch from state.yaml and re-run.');
|
||||
console.warn(
|
||||
'To proceed, remove the custom_patch from state.yaml and re-run.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
252
scripts/validate-all-skills.ts
Normal file
252
scripts/validate-all-skills.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Validate all skills by applying each in isolation against current main.
|
||||
*
|
||||
* For each skill:
|
||||
* 1. Reset working tree to clean state
|
||||
* 2. Initialize .nanoclaw/ (snapshot current source as base)
|
||||
* 3. Apply skill via apply-skill.ts
|
||||
* 4. Run tsc --noEmit (typecheck)
|
||||
* 5. Run the skill's test command (from manifest.yaml)
|
||||
*
|
||||
* Sets GitHub Actions outputs:
|
||||
* drifted — "true" | "false"
|
||||
* drifted_skills — JSON array of drifted skill names, e.g. ["add-telegram"]
|
||||
* results — JSON array of per-skill results
|
||||
*
|
||||
* Exit code 1 if any skill drifted, 0 otherwise.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/validate-all-skills.ts # validate all
|
||||
* npx tsx scripts/validate-all-skills.ts add-telegram # validate one
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { parse } from 'yaml';
|
||||
import type { SkillManifest } from '../skills-engine/types.js';
|
||||
|
||||
interface SkillValidationResult {
|
||||
name: string;
|
||||
success: boolean;
|
||||
failedStep?: 'apply' | 'typecheck' | 'test';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function discoverSkills(
|
||||
skillsDir: string,
|
||||
): { name: string; dir: string; manifest: SkillManifest }[] {
|
||||
if (!fs.existsSync(skillsDir)) return [];
|
||||
const results: { name: string; dir: string; manifest: SkillManifest }[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const manifestPath = path.join(skillsDir, entry.name, 'manifest.yaml');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
const manifest = parse(
|
||||
fs.readFileSync(manifestPath, 'utf-8'),
|
||||
) as SkillManifest;
|
||||
results.push({
|
||||
name: entry.name,
|
||||
dir: path.join(skillsDir, entry.name),
|
||||
manifest,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Restore tracked files and remove untracked skill artifacts. */
|
||||
function resetWorkingTree(): void {
|
||||
execSync('git checkout -- .', { stdio: 'pipe' });
|
||||
// Remove untracked files added by skill application (e.g. src/channels/telegram.ts)
|
||||
// but preserve node_modules to avoid costly reinstalls.
|
||||
execSync('git clean -fd --exclude=node_modules', { stdio: 'pipe' });
|
||||
// Clean skills-system state directory
|
||||
if (fs.existsSync('.nanoclaw')) {
|
||||
fs.rmSync('.nanoclaw', { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function initNanoclaw(): void {
|
||||
execSync(
|
||||
'npx tsx -e "import { initNanoclawDir } from \'./skills-engine/index\'; initNanoclawDir();"',
|
||||
{ stdio: 'pipe', timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Append a key=value to $GITHUB_OUTPUT (no-op locally). */
|
||||
function setOutput(key: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (!outputFile) return;
|
||||
|
||||
if (value.includes('\n')) {
|
||||
const delimiter = `ghadelim_${Date.now()}`;
|
||||
fs.appendFileSync(
|
||||
outputFile,
|
||||
`${key}<<${delimiter}\n${value}\n${delimiter}\n`,
|
||||
);
|
||||
} else {
|
||||
fs.appendFileSync(outputFile, `${key}=${value}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(s: string, max = 300): string {
|
||||
return s.length > max ? s.slice(0, max) + '...' : s;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const skillsDir = path.join(projectRoot, '.claude', 'skills');
|
||||
|
||||
// Allow filtering to specific skills via CLI args
|
||||
const filterSkills = process.argv.slice(2);
|
||||
|
||||
let skills = discoverSkills(skillsDir);
|
||||
if (filterSkills.length > 0) {
|
||||
skills = skills.filter((s) => filterSkills.includes(s.name));
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
console.log('No skills found to validate.');
|
||||
setOutput('drifted', 'false');
|
||||
setOutput('drifted_skills', '[]');
|
||||
setOutput('results', '[]');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Validating ${skills.length} skill(s): ${skills.map((s) => s.name).join(', ')}\n`,
|
||||
);
|
||||
|
||||
const results: SkillValidationResult[] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
console.log(`--- ${skill.name} ---`);
|
||||
|
||||
// Clean slate
|
||||
resetWorkingTree();
|
||||
initNanoclaw();
|
||||
|
||||
// Step 1: Apply skill
|
||||
try {
|
||||
const applyOutput = execSync(
|
||||
`npx tsx scripts/apply-skill.ts "${skill.dir}"`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
// parse stdout to verify success
|
||||
try {
|
||||
const parsed = JSON.parse(applyOutput);
|
||||
if (!parsed.success) {
|
||||
console.log(` FAIL (apply): ${truncate(parsed.error || 'unknown')}`);
|
||||
results.push({
|
||||
name: skill.name,
|
||||
success: false,
|
||||
failedStep: 'apply',
|
||||
error: parsed.error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON stdout with exit 0 is treated as success
|
||||
}
|
||||
} catch (err: any) {
|
||||
const stderr = err.stderr?.toString() || '';
|
||||
const stdout = err.stdout?.toString() || '';
|
||||
let error = 'Apply failed';
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
error = parsed.error || error;
|
||||
} catch {
|
||||
error = stderr || stdout || err.message;
|
||||
}
|
||||
console.log(` FAIL (apply): ${truncate(error)}`);
|
||||
results.push({
|
||||
name: skill.name,
|
||||
success: false,
|
||||
failedStep: 'apply',
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
console.log(' apply: OK');
|
||||
|
||||
// Step 2: Typecheck
|
||||
try {
|
||||
execSync('npx tsc --noEmit', {
|
||||
stdio: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const error = err.stdout?.toString() || err.message;
|
||||
console.log(` FAIL (typecheck): ${truncate(error)}`);
|
||||
results.push({
|
||||
name: skill.name,
|
||||
success: false,
|
||||
failedStep: 'typecheck',
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
console.log(' typecheck: OK');
|
||||
|
||||
// Step 3: Skill's own test command
|
||||
if (skill.manifest.test) {
|
||||
try {
|
||||
execSync(skill.manifest.test, {
|
||||
stdio: 'pipe',
|
||||
timeout: 300_000,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const error =
|
||||
err.stdout?.toString() || err.stderr?.toString() || err.message;
|
||||
console.log(` FAIL (test): ${truncate(error)}`);
|
||||
results.push({
|
||||
name: skill.name,
|
||||
success: false,
|
||||
failedStep: 'test',
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
console.log(' test: OK');
|
||||
}
|
||||
|
||||
console.log(' PASS');
|
||||
results.push({ name: skill.name, success: true });
|
||||
}
|
||||
|
||||
// Restore clean state
|
||||
resetWorkingTree();
|
||||
|
||||
// Summary
|
||||
const drifted = results.filter((r) => !r.success);
|
||||
const passed = results.filter((r) => r.success);
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
for (const r of results) {
|
||||
const status = r.success ? 'PASS' : 'FAIL';
|
||||
const detail = r.failedStep ? ` (${r.failedStep})` : '';
|
||||
console.log(` ${status} ${r.name}${detail}`);
|
||||
}
|
||||
console.log(`\n${passed.length} passed, ${drifted.length} failed`);
|
||||
|
||||
// GitHub Actions outputs
|
||||
setOutput('drifted', drifted.length > 0 ? 'true' : 'false');
|
||||
setOutput('drifted_skills', JSON.stringify(drifted.map((d) => d.name)));
|
||||
setOutput('results', JSON.stringify(results));
|
||||
|
||||
if (drifted.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -42,8 +42,13 @@ export async function run(args: string[]): Promise<void> {
|
||||
// Validate runtime availability
|
||||
if (runtime === 'apple-container' && !commandExists('container')) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
|
||||
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'runtime_not_available',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -51,8 +56,13 @@ export async function run(args: string[]): Promise<void> {
|
||||
if (runtime === 'docker') {
|
||||
if (!commandExists('docker')) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
|
||||
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'runtime_not_available',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -60,8 +70,13 @@ export async function run(args: string[]): Promise<void> {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
} catch {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
|
||||
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'runtime_not_available',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -69,13 +84,19 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
if (!['apple-container', 'docker'].includes(runtime)) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
|
||||
STATUS: 'failed', ERROR: 'unknown_runtime', LOG: 'logs/setup.log',
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'unknown_runtime',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
const buildCmd = runtime === 'apple-container' ? 'container build' : 'docker build';
|
||||
const buildCmd =
|
||||
runtime === 'apple-container' ? 'container build' : 'docker build';
|
||||
const runCmd = runtime === 'apple-container' ? 'container' : 'docker';
|
||||
|
||||
// Build
|
||||
|
||||
@@ -34,9 +34,9 @@ describe('registered groups DB query', () => {
|
||||
});
|
||||
|
||||
it('returns 0 for empty table', () => {
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM registered_groups',
|
||||
).get() as { count: number };
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(0);
|
||||
});
|
||||
|
||||
@@ -44,36 +44,54 @@ describe('registered groups DB query', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run('123@g.us', 'Group 1', 'group-1', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Group 1',
|
||||
'group-1',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run('456@g.us', 'Group 2', 'group-2', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||
).run(
|
||||
'456@g.us',
|
||||
'Group 2',
|
||||
'group-2',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM registered_groups',
|
||||
).get() as { count: number };
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentials detection', () => {
|
||||
it('detects ANTHROPIC_API_KEY in env content', () => {
|
||||
const content = 'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
|
||||
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
const content =
|
||||
'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => {
|
||||
const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123';
|
||||
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no credentials', () => {
|
||||
const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo';
|
||||
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
expect(hasCredentials).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,9 +43,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const hasEnv = fs.existsSync(path.join(projectRoot, '.env'));
|
||||
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasAuth =
|
||||
fs.existsSync(authDir) &&
|
||||
fs.readdirSync(authDir).length > 0;
|
||||
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
let hasRegisteredGroups = false;
|
||||
// Check JSON file first (pre-migration)
|
||||
@@ -57,9 +55,9 @@ export async function run(_args: string[]): Promise<void> {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM registered_groups',
|
||||
).get() as { count: number };
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
if (row.count > 0) hasRegisteredGroups = true;
|
||||
db.close();
|
||||
} catch {
|
||||
@@ -68,8 +66,18 @@ export async function run(_args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ platform, wsl, appleContainer, docker, hasEnv, hasAuth, hasRegisteredGroups },
|
||||
'Environment check complete');
|
||||
logger.info(
|
||||
{
|
||||
platform,
|
||||
wsl,
|
||||
appleContainer,
|
||||
docker,
|
||||
hasEnv,
|
||||
hasAuth,
|
||||
hasRegisteredGroups,
|
||||
},
|
||||
'Environment check complete',
|
||||
);
|
||||
|
||||
emitStatus('CHECK_ENVIRONMENT', {
|
||||
PLATFORM: platform,
|
||||
|
||||
@@ -17,7 +17,10 @@ function parseArgs(args: string[]): { list: boolean; limit: number } {
|
||||
let limit = 30;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--list') list = true;
|
||||
if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; }
|
||||
if (args[i] === '--limit' && args[i + 1]) {
|
||||
limit = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { list, limit };
|
||||
}
|
||||
@@ -43,12 +46,14 @@ async function listGroups(limit: number): Promise<void> {
|
||||
}
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare(
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT jid, name FROM chats
|
||||
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
|
||||
ORDER BY last_message_time DESC
|
||||
LIMIT ?`,
|
||||
).all(limit) as Array<{ jid: string; name: string }>;
|
||||
)
|
||||
.all(limit) as Array<{ jid: string; name: string }>;
|
||||
db.close();
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -153,12 +158,15 @@ sock.ev.on('connection.update', async (update) => {
|
||||
});
|
||||
`;
|
||||
|
||||
const output = execSync(`node --input-type=module -e ${JSON.stringify(syncScript)}`, {
|
||||
const output = execSync(
|
||||
`node --input-type=module -e ${JSON.stringify(syncScript)}`,
|
||||
{
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf-8',
|
||||
timeout: 45000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
},
|
||||
);
|
||||
syncOk = output.includes('SYNCED:');
|
||||
logger.info({ output: output.trim() }, 'Sync output');
|
||||
} catch (err) {
|
||||
@@ -171,9 +179,11 @@ sock.ev.on('connection.update', async (update) => {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db.prepare(
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
|
||||
).get() as { count: number };
|
||||
)
|
||||
.get() as { count: number };
|
||||
groupsInDb = row.count;
|
||||
db.close();
|
||||
} catch {
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import { logger } from '../src/logger.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const STEPS: Record<string, () => Promise<{ run: (args: string[]) => Promise<void> }>> = {
|
||||
const STEPS: Record<
|
||||
string,
|
||||
() => Promise<{ run: (args: string[]) => Promise<void> }>
|
||||
> = {
|
||||
environment: () => import('./environment.js'),
|
||||
container: () => import('./container.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
@@ -21,12 +24,16 @@ async function main(): Promise<void> {
|
||||
const stepIdx = args.indexOf('--step');
|
||||
|
||||
if (stepIdx === -1 || !args[stepIdx + 1]) {
|
||||
console.error(`Usage: npx tsx setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`);
|
||||
console.error(
|
||||
`Usage: npx tsx setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stepName = args[stepIdx + 1];
|
||||
const stepArgs = args.filter((a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--');
|
||||
const stepArgs = args.filter(
|
||||
(a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--',
|
||||
);
|
||||
|
||||
const loader = STEPS[stepName];
|
||||
if (!loader) {
|
||||
|
||||
@@ -15,7 +15,10 @@ function parseArgs(args: string[]): { empty: boolean; json: string } {
|
||||
let json = '';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--empty') empty = true;
|
||||
if (args[i] === '--json' && args[i + 1]) { json = args[i + 1]; i++; }
|
||||
if (args[i] === '--json' && args[i + 1]) {
|
||||
json = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { empty, json };
|
||||
}
|
||||
@@ -27,7 +30,9 @@ export async function run(args: string[]): Promise<void> {
|
||||
const configFile = path.join(configDir, 'mount-allowlist.json');
|
||||
|
||||
if (isRoot()) {
|
||||
logger.warn('Running as root — mount allowlist will be written to root home directory');
|
||||
logger.warn(
|
||||
'Running as root — mount allowlist will be written to root home directory',
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
@@ -63,7 +68,9 @@ export async function run(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
|
||||
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
|
||||
allowedRoots = Array.isArray(parsed.allowedRoots)
|
||||
? parsed.allowedRoots.length
|
||||
: 0;
|
||||
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
|
||||
} else {
|
||||
// Read from stdin
|
||||
@@ -87,11 +94,16 @@ export async function run(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
|
||||
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
|
||||
allowedRoots = Array.isArray(parsed.allowedRoots)
|
||||
? parsed.allowedRoots.length
|
||||
: 0;
|
||||
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
|
||||
}
|
||||
|
||||
logger.info({ configFile, allowedRoots, nonMainReadOnly }, 'Allowlist configured');
|
||||
logger.info(
|
||||
{ configFile, allowedRoots, nonMainReadOnly },
|
||||
'Allowlist configured',
|
||||
);
|
||||
|
||||
emitStatus('CONFIGURE_MOUNTS', {
|
||||
PATH: configFile,
|
||||
|
||||
@@ -19,7 +19,6 @@ describe('getPlatform', () => {
|
||||
const result = getPlatform();
|
||||
expect(['macos', 'linux', 'unknown']).toContain(result);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// --- isWSL ---
|
||||
|
||||
@@ -73,7 +73,9 @@ export function openBrowser(url: string): boolean {
|
||||
// WSL without wslview: try cmd.exe
|
||||
if (isWSL()) {
|
||||
try {
|
||||
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: 'ignore' });
|
||||
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// cmd.exe not available
|
||||
|
||||
@@ -35,9 +35,18 @@ describe('parameterized SQL registration', () => {
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run('123@g.us', 'Test Group', 'test-group', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Test Group',
|
||||
'test-group',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get('123@g.us') as {
|
||||
const row = db
|
||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
||||
.get('123@g.us') as {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
@@ -59,9 +68,18 @@ describe('parameterized SQL registration', () => {
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run('456@g.us', name, 'obriens-group', '@Andy', '2024-01-01T00:00:00.000Z', 0);
|
||||
).run(
|
||||
'456@g.us',
|
||||
name,
|
||||
'obriens-group',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT name FROM registered_groups WHERE jid = ?').get('456@g.us') as {
|
||||
const row = db
|
||||
.prepare('SELECT name FROM registered_groups WHERE jid = ?')
|
||||
.get('456@g.us') as {
|
||||
name: string;
|
||||
};
|
||||
|
||||
@@ -78,12 +96,16 @@ describe('parameterized SQL registration', () => {
|
||||
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||
|
||||
// Table should still exist and have the row
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM registered_groups').get() as {
|
||||
const count = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as {
|
||||
count: number;
|
||||
};
|
||||
expect(count.count).toBe(1);
|
||||
|
||||
const row = db.prepare('SELECT jid FROM registered_groups').get() as { jid: string };
|
||||
const row = db.prepare('SELECT jid FROM registered_groups').get() as {
|
||||
jid: string;
|
||||
};
|
||||
expect(row.jid).toBe(maliciousJid);
|
||||
});
|
||||
|
||||
@@ -92,9 +114,17 @@ describe('parameterized SQL registration', () => {
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run('789@s.whatsapp.net', 'Personal', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 0);
|
||||
).run(
|
||||
'789@s.whatsapp.net',
|
||||
'Personal',
|
||||
'main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const row = db.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
|
||||
const row = db
|
||||
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
|
||||
.get('789@s.whatsapp.net') as { requires_trigger: number };
|
||||
|
||||
expect(row.requires_trigger).toBe(0);
|
||||
@@ -107,13 +137,31 @@ describe('parameterized SQL registration', () => {
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
);
|
||||
|
||||
stmt.run('123@g.us', 'Original', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||
stmt.run('123@g.us', 'Updated', 'main', '@Bot', '2024-02-01T00:00:00.000Z', 0);
|
||||
stmt.run(
|
||||
'123@g.us',
|
||||
'Original',
|
||||
'main',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
stmt.run(
|
||||
'123@g.us',
|
||||
'Updated',
|
||||
'main',
|
||||
'@Bot',
|
||||
'2024-02-01T00:00:00.000Z',
|
||||
0,
|
||||
);
|
||||
|
||||
const rows = db.prepare('SELECT * FROM registered_groups').all();
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
const row = rows[0] as { name: string; trigger_pattern: string; requires_trigger: number };
|
||||
const row = rows[0] as {
|
||||
name: string;
|
||||
trigger_pattern: string;
|
||||
requires_trigger: number;
|
||||
};
|
||||
expect(row.name).toBe('Updated');
|
||||
expect(row.trigger_pattern).toBe('@Bot');
|
||||
expect(row.requires_trigger).toBe(0);
|
||||
|
||||
@@ -35,12 +35,24 @@ function parseArgs(args: string[]): RegisterArgs {
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--jid': result.jid = args[++i] || ''; break;
|
||||
case '--name': result.name = args[++i] || ''; break;
|
||||
case '--trigger': result.trigger = args[++i] || ''; break;
|
||||
case '--folder': result.folder = args[++i] || ''; break;
|
||||
case '--no-trigger-required': result.requiresTrigger = false; break;
|
||||
case '--assistant-name': result.assistantName = args[++i] || 'Andy'; break;
|
||||
case '--jid':
|
||||
result.jid = args[++i] || '';
|
||||
break;
|
||||
case '--name':
|
||||
result.name = args[++i] || '';
|
||||
break;
|
||||
case '--trigger':
|
||||
result.trigger = args[++i] || '';
|
||||
break;
|
||||
case '--folder':
|
||||
result.folder = args[++i] || '';
|
||||
break;
|
||||
case '--no-trigger-required':
|
||||
result.requiresTrigger = false;
|
||||
break;
|
||||
case '--assistant-name':
|
||||
result.assistantName = args[++i] || 'Andy';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,18 +107,30 @@ export async function run(args: string[]): Promise<void> {
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(parsed.jid, parsed.name, parsed.folder, parsed.trigger, timestamp, requiresTriggerInt);
|
||||
).run(
|
||||
parsed.jid,
|
||||
parsed.name,
|
||||
parsed.folder,
|
||||
parsed.trigger,
|
||||
timestamp,
|
||||
requiresTriggerInt,
|
||||
);
|
||||
|
||||
db.close();
|
||||
logger.info('Wrote registration to SQLite');
|
||||
|
||||
// Create group folders
|
||||
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Update assistant name in CLAUDE.md files if different from default
|
||||
let nameUpdated = false;
|
||||
if (parsed.assistantName !== 'Andy') {
|
||||
logger.info({ from: 'Andy', to: parsed.assistantName }, 'Updating assistant name');
|
||||
logger.info(
|
||||
{ from: 'Andy', to: parsed.assistantName },
|
||||
'Updating assistant name',
|
||||
);
|
||||
|
||||
const mdFiles = [
|
||||
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
|
||||
@@ -117,7 +141,10 @@ export async function run(args: string[]): Promise<void> {
|
||||
if (fs.existsSync(mdFile)) {
|
||||
let content = fs.readFileSync(mdFile, 'utf-8');
|
||||
content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
|
||||
content = content.replace(
|
||||
/You are Andy/g,
|
||||
`You are ${parsed.assistantName}`,
|
||||
);
|
||||
fs.writeFileSync(mdFile, content);
|
||||
logger.info({ file: mdFile }, 'Updated CLAUDE.md');
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import path from 'path';
|
||||
*/
|
||||
|
||||
// Helper: generate a plist string the same way service.ts does
|
||||
function generatePlist(nodePath: string, projectRoot: string, homeDir: string): string {
|
||||
function generatePlist(
|
||||
nodePath: string,
|
||||
projectRoot: string,
|
||||
homeDir: string,
|
||||
): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
@@ -69,22 +73,38 @@ WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
|
||||
|
||||
describe('plist generation', () => {
|
||||
it('contains the correct label', () => {
|
||||
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
|
||||
const plist = generatePlist(
|
||||
'/usr/local/bin/node',
|
||||
'/home/user/nanoclaw',
|
||||
'/home/user',
|
||||
);
|
||||
expect(plist).toContain('<string>com.nanoclaw</string>');
|
||||
});
|
||||
|
||||
it('uses the correct node path', () => {
|
||||
const plist = generatePlist('/opt/node/bin/node', '/home/user/nanoclaw', '/home/user');
|
||||
const plist = generatePlist(
|
||||
'/opt/node/bin/node',
|
||||
'/home/user/nanoclaw',
|
||||
'/home/user',
|
||||
);
|
||||
expect(plist).toContain('<string>/opt/node/bin/node</string>');
|
||||
});
|
||||
|
||||
it('points to dist/index.js', () => {
|
||||
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
|
||||
const plist = generatePlist(
|
||||
'/usr/local/bin/node',
|
||||
'/home/user/nanoclaw',
|
||||
'/home/user',
|
||||
);
|
||||
expect(plist).toContain('/home/user/nanoclaw/dist/index.js');
|
||||
});
|
||||
|
||||
it('sets log paths', () => {
|
||||
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
|
||||
const plist = generatePlist(
|
||||
'/usr/local/bin/node',
|
||||
'/home/user/nanoclaw',
|
||||
'/home/user',
|
||||
);
|
||||
expect(plist).toContain('nanoclaw.log');
|
||||
expect(plist).toContain('nanoclaw.error.log');
|
||||
});
|
||||
@@ -92,24 +112,46 @@ describe('plist generation', () => {
|
||||
|
||||
describe('systemd unit generation', () => {
|
||||
it('user unit uses default.target', () => {
|
||||
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
|
||||
const unit = generateSystemdUnit(
|
||||
'/usr/bin/node',
|
||||
'/home/user/nanoclaw',
|
||||
'/home/user',
|
||||
false,
|
||||
);
|
||||
expect(unit).toContain('WantedBy=default.target');
|
||||
});
|
||||
|
||||
it('system unit uses multi-user.target', () => {
|
||||
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', true);
|
||||
const unit = generateSystemdUnit(
|
||||
'/usr/bin/node',
|
||||
'/home/user/nanoclaw',
|
||||
'/home/user',
|
||||
true,
|
||||
);
|
||||
expect(unit).toContain('WantedBy=multi-user.target');
|
||||
});
|
||||
|
||||
it('contains restart policy', () => {
|
||||
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
|
||||
const unit = generateSystemdUnit(
|
||||
'/usr/bin/node',
|
||||
'/home/user/nanoclaw',
|
||||
'/home/user',
|
||||
false,
|
||||
);
|
||||
expect(unit).toContain('Restart=always');
|
||||
expect(unit).toContain('RestartSec=5');
|
||||
});
|
||||
|
||||
it('sets correct ExecStart', () => {
|
||||
const unit = generateSystemdUnit('/usr/bin/node', '/srv/nanoclaw', '/home/user', false);
|
||||
expect(unit).toContain('ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js');
|
||||
const unit = generateSystemdUnit(
|
||||
'/usr/bin/node',
|
||||
'/srv/nanoclaw',
|
||||
'/home/user',
|
||||
false,
|
||||
);
|
||||
expect(unit).toContain(
|
||||
'ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,8 +68,17 @@ export async function run(_args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): void {
|
||||
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', 'com.nanoclaw.plist');
|
||||
function setupLaunchd(
|
||||
projectRoot: string,
|
||||
nodePath: string,
|
||||
homeDir: string,
|
||||
): void {
|
||||
const plistPath = path.join(
|
||||
homeDir,
|
||||
'Library',
|
||||
'LaunchAgents',
|
||||
'com.nanoclaw.plist',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
||||
|
||||
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -107,7 +116,9 @@ function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): v
|
||||
logger.info({ plistPath }, 'Wrote launchd plist');
|
||||
|
||||
try {
|
||||
execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore' });
|
||||
execSync(`launchctl load ${JSON.stringify(plistPath)}`, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
logger.info('launchctl load succeeded');
|
||||
} catch {
|
||||
logger.warn('launchctl load failed (may already be loaded)');
|
||||
@@ -133,7 +144,11 @@ function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): v
|
||||
});
|
||||
}
|
||||
|
||||
function setupLinux(projectRoot: string, nodePath: string, homeDir: string): void {
|
||||
function setupLinux(
|
||||
projectRoot: string,
|
||||
nodePath: string,
|
||||
homeDir: string,
|
||||
): void {
|
||||
const serviceManager = getServiceManager();
|
||||
|
||||
if (serviceManager === 'systemd') {
|
||||
@@ -186,7 +201,11 @@ function checkDockerGroupStale(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function setupSystemd(projectRoot: string, nodePath: string, homeDir: string): void {
|
||||
function setupSystemd(
|
||||
projectRoot: string,
|
||||
nodePath: string,
|
||||
homeDir: string,
|
||||
): void {
|
||||
const runningAsRoot = isRoot();
|
||||
|
||||
// Root uses system-level service, non-root uses user-level
|
||||
@@ -202,7 +221,9 @@ function setupSystemd(projectRoot: string, nodePath: string, homeDir: string): v
|
||||
try {
|
||||
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
|
||||
} catch {
|
||||
logger.warn('systemd user session not available — falling back to nohup wrapper');
|
||||
logger.warn(
|
||||
'systemd user session not available — falling back to nohup wrapper',
|
||||
);
|
||||
setupNohupFallback(projectRoot, nodePath, homeDir);
|
||||
return;
|
||||
}
|
||||
@@ -284,7 +305,11 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
||||
});
|
||||
}
|
||||
|
||||
function setupNohupFallback(projectRoot: string, nodePath: string, homeDir: string): void {
|
||||
function setupNohupFallback(
|
||||
projectRoot: string,
|
||||
nodePath: string,
|
||||
homeDir: string,
|
||||
): void {
|
||||
logger.warn('No systemd detected — generating nohup wrapper script');
|
||||
|
||||
const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh');
|
||||
|
||||
@@ -13,7 +13,12 @@ import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { logger } from '../src/logger.js';
|
||||
import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js';
|
||||
import {
|
||||
getPlatform,
|
||||
getServiceManager,
|
||||
hasSystemd,
|
||||
isRoot,
|
||||
} from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
@@ -48,7 +53,9 @@ export async function run(_args: string[]): Promise<void> {
|
||||
service = 'running';
|
||||
} catch {
|
||||
try {
|
||||
const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8' });
|
||||
const output = execSync(`${prefix} list-unit-files`, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (output.includes('nanoclaw')) {
|
||||
service = 'stopped';
|
||||
}
|
||||
@@ -110,9 +117,9 @@ export async function run(_args: string[]): Promise<void> {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM registered_groups',
|
||||
).get() as { count: number };
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
registeredGroups = row.count;
|
||||
db.close();
|
||||
} catch {
|
||||
@@ -122,7 +129,11 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
// 6. Check mount allowlist
|
||||
let mountAllowlist = 'missing';
|
||||
if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) {
|
||||
if (
|
||||
fs.existsSync(
|
||||
path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'),
|
||||
)
|
||||
) {
|
||||
mountAllowlist = 'configured';
|
||||
}
|
||||
|
||||
|
||||
@@ -66,8 +66,14 @@ function parseArgs(args: string[]): { method: string; phone: string } {
|
||||
let method = '';
|
||||
let phone = '';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--method' && args[i + 1]) { method = args[i + 1]; i++; }
|
||||
if (args[i] === '--phone' && args[i + 1]) { phone = args[i + 1]; i++; }
|
||||
if (args[i] === '--method' && args[i + 1]) {
|
||||
method = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
if (args[i] === '--phone' && args[i + 1]) {
|
||||
phone = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { method, phone };
|
||||
}
|
||||
@@ -87,7 +93,10 @@ function readFileSafe(filePath: string): string {
|
||||
function getPhoneNumber(projectRoot: string): string {
|
||||
try {
|
||||
const creds = JSON.parse(
|
||||
fs.readFileSync(path.join(projectRoot, 'store', 'auth', 'creds.json'), 'utf-8'),
|
||||
fs.readFileSync(
|
||||
path.join(projectRoot, 'store', 'auth', 'creds.json'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
if (creds.me?.id) {
|
||||
return creds.me.id.split(':')[0].split('@')[0];
|
||||
@@ -121,18 +130,24 @@ export async function run(args: string[]): Promise<void> {
|
||||
const qrFile = path.join(projectRoot, 'store', 'qr-data.txt');
|
||||
|
||||
if (!method) {
|
||||
emitAuthStatus('unknown', 'failed', 'failed', { ERROR: 'missing_method_flag' });
|
||||
emitAuthStatus('unknown', 'failed', 'failed', {
|
||||
ERROR: 'missing_method_flag',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// qr-terminal is a manual flow
|
||||
if (method === 'qr-terminal') {
|
||||
emitAuthStatus('qr-terminal', 'manual', 'manual', { PROJECT_PATH: projectRoot });
|
||||
emitAuthStatus('qr-terminal', 'manual', 'manual', {
|
||||
PROJECT_PATH: projectRoot,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'pairing-code' && !phone) {
|
||||
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'missing_phone_number' });
|
||||
emitAuthStatus('pairing-code', 'failed', 'failed', {
|
||||
ERROR: 'missing_phone_number',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
@@ -143,12 +158,28 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
// Clean stale state
|
||||
logger.info({ method }, 'Starting WhatsApp auth');
|
||||
try { fs.rmSync(path.join(projectRoot, 'store', 'auth'), { recursive: true, force: true }); } catch { /* ok */ }
|
||||
try { fs.unlinkSync(qrFile); } catch { /* ok */ }
|
||||
try { fs.unlinkSync(statusFile); } catch { /* ok */ }
|
||||
try {
|
||||
fs.rmSync(path.join(projectRoot, 'store', 'auth'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(qrFile);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(statusFile);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
|
||||
// Start auth process in background
|
||||
const authArgs = method === 'pairing-code'
|
||||
const authArgs =
|
||||
method === 'pairing-code'
|
||||
? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone]
|
||||
: ['src/whatsapp-auth.ts'];
|
||||
|
||||
@@ -165,7 +196,11 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
// Cleanup on exit
|
||||
const cleanup = () => {
|
||||
try { authProc.kill(); } catch { /* ok */ }
|
||||
try {
|
||||
authProc.kill();
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
|
||||
@@ -221,10 +256,14 @@ async function handleQrBrowser(
|
||||
if (!isHeadless()) {
|
||||
const opened = openBrowser(htmlPath);
|
||||
if (!opened) {
|
||||
logger.warn('Could not open browser — display QR in terminal as fallback');
|
||||
logger.warn(
|
||||
'Could not open browser — display QR in terminal as fallback',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info('Headless environment — QR HTML saved but browser not opened');
|
||||
logger.info(
|
||||
'Headless environment — QR HTML saved but browser not opened',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to generate QR HTML');
|
||||
@@ -261,15 +300,24 @@ async function handlePairingCode(
|
||||
}
|
||||
|
||||
if (!pairingCode) {
|
||||
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'pairing_code_timeout' });
|
||||
emitAuthStatus('pairing-code', 'failed', 'failed', {
|
||||
ERROR: 'pairing_code_timeout',
|
||||
});
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// Emit pairing code immediately so the caller can display it to the user
|
||||
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', { PAIRING_CODE: pairingCode });
|
||||
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', {
|
||||
PAIRING_CODE: pairingCode,
|
||||
});
|
||||
|
||||
// Poll for completion (120s)
|
||||
await pollAuthCompletion('pairing-code', statusFile, projectRoot, pairingCode);
|
||||
await pollAuthCompletion(
|
||||
'pairing-code',
|
||||
statusFile,
|
||||
projectRoot,
|
||||
pairingCode,
|
||||
);
|
||||
}
|
||||
|
||||
async function pollAuthCompletion(
|
||||
|
||||
@@ -26,10 +26,14 @@ describe('backup', () => {
|
||||
createBackup(['src/app.ts']);
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'app.ts'), 'modified content');
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe('modified content');
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe(
|
||||
'modified content',
|
||||
);
|
||||
|
||||
restoreBackup();
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe('original content');
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe(
|
||||
'original content',
|
||||
);
|
||||
});
|
||||
|
||||
it('createBackup skips missing files without error', () => {
|
||||
@@ -51,7 +55,13 @@ describe('backup', () => {
|
||||
it('createBackup writes tombstone for non-existent files', () => {
|
||||
createBackup(['src/newfile.ts']);
|
||||
|
||||
const tombstone = path.join(tmpDir, '.nanoclaw', 'backup', 'src', 'newfile.ts.tombstone');
|
||||
const tombstone = path.join(
|
||||
tmpDir,
|
||||
'.nanoclaw',
|
||||
'backup',
|
||||
'src',
|
||||
'newfile.ts.tombstone',
|
||||
);
|
||||
expect(fs.existsSync(tombstone)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import {
|
||||
computeOverlapMatrix,
|
||||
extractOverlapInfo,
|
||||
generateMatrix,
|
||||
type SkillOverlapInfo,
|
||||
} from '../../scripts/generate-ci-matrix.js';
|
||||
import { SkillManifest } from '../types.js';
|
||||
import { createTempDir, cleanup } from './test-helpers.js';
|
||||
|
||||
function makeManifest(overrides: Partial<SkillManifest> & { skill: string }): SkillManifest {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
description: 'Test skill',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ci-matrix', () => {
|
||||
describe('computeOverlapMatrix', () => {
|
||||
it('detects overlap from shared modifies entries', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'telegram', modifies: ['src/config.ts', 'src/index.ts'], npmDependencies: [] },
|
||||
{ name: 'discord', modifies: ['src/config.ts', 'src/router.ts'], npmDependencies: [] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].skills).toEqual(['telegram', 'discord']);
|
||||
expect(matrix[0].reason).toContain('shared modifies');
|
||||
expect(matrix[0].reason).toContain('src/config.ts');
|
||||
});
|
||||
|
||||
it('returns no entry for non-overlapping skills', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'telegram', modifies: ['src/telegram.ts'], npmDependencies: ['grammy'] },
|
||||
{ name: 'discord', modifies: ['src/discord.ts'], npmDependencies: ['discord.js'] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects overlap from shared npm dependencies', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'skill-a', modifies: ['src/a.ts'], npmDependencies: ['lodash', 'zod'] },
|
||||
{ name: 'skill-b', modifies: ['src/b.ts'], npmDependencies: ['zod', 'express'] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].skills).toEqual(['skill-a', 'skill-b']);
|
||||
expect(matrix[0].reason).toContain('shared npm packages');
|
||||
expect(matrix[0].reason).toContain('zod');
|
||||
});
|
||||
|
||||
it('reports both modifies and npm overlap in one entry', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'skill-a', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
|
||||
{ name: 'skill-b', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].reason).toContain('shared modifies');
|
||||
expect(matrix[0].reason).toContain('shared npm packages');
|
||||
});
|
||||
|
||||
it('handles three skills with pairwise overlaps', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'a', modifies: ['src/config.ts'], npmDependencies: [] },
|
||||
{ name: 'b', modifies: ['src/config.ts', 'src/router.ts'], npmDependencies: [] },
|
||||
{ name: 'c', modifies: ['src/router.ts'], npmDependencies: [] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
// a-b overlap on config.ts, b-c overlap on router.ts, a-c no overlap
|
||||
expect(matrix).toHaveLength(2);
|
||||
expect(matrix[0].skills).toEqual(['a', 'b']);
|
||||
expect(matrix[1].skills).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
it('returns empty array for single skill', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'only', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array for no skills', () => {
|
||||
const matrix = computeOverlapMatrix([]);
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractOverlapInfo', () => {
|
||||
it('extracts modifies and npm dependencies using dirName', () => {
|
||||
const manifest = makeManifest({
|
||||
skill: 'telegram',
|
||||
modifies: ['src/config.ts'],
|
||||
structured: {
|
||||
npm_dependencies: { grammy: '^1.0.0', zod: '^3.0.0' },
|
||||
},
|
||||
});
|
||||
|
||||
const info = extractOverlapInfo(manifest, 'add-telegram');
|
||||
|
||||
expect(info.name).toBe('add-telegram');
|
||||
expect(info.modifies).toEqual(['src/config.ts']);
|
||||
expect(info.npmDependencies).toEqual(['grammy', 'zod']);
|
||||
});
|
||||
|
||||
it('handles manifest without structured field', () => {
|
||||
const manifest = makeManifest({
|
||||
skill: 'simple',
|
||||
modifies: ['src/index.ts'],
|
||||
});
|
||||
|
||||
const info = extractOverlapInfo(manifest, 'add-simple');
|
||||
|
||||
expect(info.npmDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles structured without npm_dependencies', () => {
|
||||
const manifest = makeManifest({
|
||||
skill: 'env-only',
|
||||
modifies: [],
|
||||
structured: {
|
||||
env_additions: ['MY_VAR'],
|
||||
},
|
||||
});
|
||||
|
||||
const info = extractOverlapInfo(manifest, 'add-env-only');
|
||||
|
||||
expect(info.npmDependencies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMatrix with real filesystem', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function createManifestDir(skillsDir: string, name: string, manifest: Record<string, unknown>): void {
|
||||
const dir = path.join(skillsDir, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify(manifest));
|
||||
}
|
||||
|
||||
it('reads manifests from disk and finds overlaps', () => {
|
||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
||||
|
||||
createManifestDir(skillsDir, 'telegram', {
|
||||
skill: 'telegram',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['src/telegram.ts'],
|
||||
modifies: ['src/config.ts', 'src/index.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
});
|
||||
|
||||
createManifestDir(skillsDir, 'discord', {
|
||||
skill: 'discord',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['src/discord.ts'],
|
||||
modifies: ['src/config.ts', 'src/index.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
});
|
||||
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].skills).toContain('telegram');
|
||||
expect(matrix[0].skills).toContain('discord');
|
||||
});
|
||||
|
||||
it('returns empty matrix when skills dir does not exist', () => {
|
||||
const matrix = generateMatrix(path.join(tmpDir, 'nonexistent'));
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty matrix for non-overlapping skills on disk', () => {
|
||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
||||
|
||||
createManifestDir(skillsDir, 'alpha', {
|
||||
skill: 'alpha',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['src/alpha.ts'],
|
||||
modifies: ['src/alpha-config.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
});
|
||||
|
||||
createManifestDir(skillsDir, 'beta', {
|
||||
skill: 'beta',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['src/beta.ts'],
|
||||
modifies: ['src/beta-config.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
});
|
||||
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects structured npm overlap from disk manifests', () => {
|
||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
||||
|
||||
createManifestDir(skillsDir, 'skill-x', {
|
||||
skill: 'skill-x',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: ['src/x.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
structured: {
|
||||
npm_dependencies: { lodash: '^4.0.0' },
|
||||
},
|
||||
});
|
||||
|
||||
createManifestDir(skillsDir, 'skill-y', {
|
||||
skill: 'skill-y',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: ['src/y.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
structured: {
|
||||
npm_dependencies: { lodash: '^4.1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].reason).toContain('lodash');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
cleanup,
|
||||
writeState,
|
||||
} from './test-helpers.js';
|
||||
import { readState, recordSkillApplication, computeFileHash } from '../state.js';
|
||||
import {
|
||||
readState,
|
||||
recordSkillApplication,
|
||||
computeFileHash,
|
||||
} from '../state.js';
|
||||
|
||||
describe('customize', () => {
|
||||
let tmpDir: string;
|
||||
@@ -116,7 +120,13 @@ describe('customize', () => {
|
||||
fs.writeFileSync(trackedFile, 'export const x = 2;');
|
||||
|
||||
// Make the base file a directory to cause diff to exit with code 2
|
||||
const baseFilePath = path.join(tmpDir, '.nanoclaw', 'base', 'src', 'app.ts');
|
||||
const baseFilePath = path.join(
|
||||
tmpDir,
|
||||
'.nanoclaw',
|
||||
'base',
|
||||
'src',
|
||||
'app.ts',
|
||||
);
|
||||
fs.mkdirSync(baseFilePath, { recursive: true });
|
||||
|
||||
expect(() => commitCustomize()).toThrow(/diff error/i);
|
||||
|
||||
@@ -16,13 +16,14 @@ describe('fetch-upstream.sh', () => {
|
||||
upstreamBareDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'nanoclaw-upstream-'),
|
||||
);
|
||||
execSync('git init --bare', { cwd: upstreamBareDir, stdio: 'pipe' });
|
||||
execSync('git init --bare -b main', {
|
||||
cwd: upstreamBareDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Create a working repo, add files, push to the bare repo
|
||||
const seedDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'nanoclaw-seed-'),
|
||||
);
|
||||
execSync('git init', { cwd: seedDir, stdio: 'pipe' });
|
||||
const seedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-seed-'));
|
||||
execSync('git init -b main', { cwd: seedDir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', {
|
||||
cwd: seedDir,
|
||||
stdio: 'pipe',
|
||||
@@ -33,10 +34,7 @@ describe('fetch-upstream.sh', () => {
|
||||
JSON.stringify({ name: 'nanoclaw', version: '2.0.0' }),
|
||||
);
|
||||
fs.mkdirSync(path.join(seedDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(seedDir, 'src/index.ts'),
|
||||
'export const v = 2;',
|
||||
);
|
||||
fs.writeFileSync(path.join(seedDir, 'src/index.ts'), 'export const v = 2;');
|
||||
execSync('git add -A && git commit -m "upstream v2.0.0"', {
|
||||
cwd: seedDir,
|
||||
stdio: 'pipe',
|
||||
@@ -45,29 +43,16 @@ describe('fetch-upstream.sh', () => {
|
||||
cwd: seedDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execSync('git push origin main 2>/dev/null || git push origin master', {
|
||||
execSync('git push origin main', {
|
||||
cwd: seedDir,
|
||||
stdio: 'pipe',
|
||||
shell: '/bin/bash',
|
||||
});
|
||||
|
||||
// Rename the default branch to main in the bare repo if needed
|
||||
try {
|
||||
execSync('git symbolic-ref HEAD refs/heads/main', {
|
||||
cwd: upstreamBareDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} catch {
|
||||
// Already on main
|
||||
}
|
||||
|
||||
fs.rmSync(seedDir, { recursive: true, force: true });
|
||||
|
||||
// Create the "project" repo that will run the script
|
||||
projectDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'nanoclaw-project-'),
|
||||
);
|
||||
execSync('git init', { cwd: projectDir, stdio: 'pipe' });
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-project-'));
|
||||
execSync('git init -b main', { cwd: projectDir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
@@ -97,7 +82,10 @@ describe('fetch-upstream.sh', () => {
|
||||
'.claude/skills/update/scripts',
|
||||
);
|
||||
fs.mkdirSync(skillScriptsDir, { recursive: true });
|
||||
fs.copyFileSync(scriptPath, path.join(skillScriptsDir, 'fetch-upstream.sh'));
|
||||
fs.copyFileSync(
|
||||
scriptPath,
|
||||
path.join(skillScriptsDir, 'fetch-upstream.sh'),
|
||||
);
|
||||
fs.chmodSync(path.join(skillScriptsDir, 'fetch-upstream.sh'), 0o755);
|
||||
});
|
||||
|
||||
@@ -124,7 +112,10 @@ describe('fetch-upstream.sh', () => {
|
||||
);
|
||||
return { stdout, exitCode: 0 };
|
||||
} catch (err: any) {
|
||||
return { stdout: (err.stdout ?? '') + (err.stderr ?? ''), exitCode: err.status ?? 1 };
|
||||
return {
|
||||
stdout: (err.stdout ?? '') + (err.stderr ?? ''),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,12 +150,12 @@ describe('fetch-upstream.sh', () => {
|
||||
expect(status.TEMP_DIR).toMatch(/^\/tmp\/nanoclaw-update-/);
|
||||
|
||||
// Verify extracted files exist
|
||||
expect(
|
||||
fs.existsSync(path.join(status.TEMP_DIR, 'package.json')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(status.TEMP_DIR, 'src/index.ts')),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(status.TEMP_DIR, 'package.json'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(status.TEMP_DIR, 'src/index.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Cleanup temp dir
|
||||
fs.rmSync(status.TEMP_DIR, { recursive: true, force: true });
|
||||
@@ -172,10 +163,10 @@ describe('fetch-upstream.sh', () => {
|
||||
|
||||
it('uses origin when it points to qwibitai/nanoclaw', () => {
|
||||
// Set origin to a URL containing qwibitai/nanoclaw
|
||||
execSync(
|
||||
`git remote add origin https://github.com/qwibitai/nanoclaw.git`,
|
||||
{ cwd: projectDir, stdio: 'pipe' },
|
||||
);
|
||||
execSync(`git remote add origin https://github.com/qwibitai/nanoclaw.git`, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
// We can't actually fetch from GitHub in tests, but we can verify
|
||||
// it picks the right remote. We'll add a second remote it CAN fetch from.
|
||||
execSync(`git remote add upstream ${upstreamBareDir}`, {
|
||||
|
||||
@@ -9,11 +9,9 @@ function shouldSkipSymlinkTests(err: unknown): boolean {
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(
|
||||
(err as { code?: string }).code === 'EPERM' ||
|
||||
((err as { code?: string }).code === 'EPERM' ||
|
||||
(err as { code?: string }).code === 'EACCES' ||
|
||||
(err as { code?: string }).code === 'ENOSYS'
|
||||
)
|
||||
(err as { code?: string }).code === 'ENOSYS')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,9 +31,10 @@ describe('file-ops', () => {
|
||||
|
||||
it('rename success', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'old.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'rename', from: 'old.ts', to: 'new.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'rename', from: 'old.ts', to: 'new.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'new.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'old.ts'))).toBe(false);
|
||||
@@ -43,9 +42,10 @@ describe('file-ops', () => {
|
||||
|
||||
it('move success', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'file.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'move', from: 'file.ts', to: 'sub/file.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'move', from: 'file.ts', to: 'sub/file.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'sub', 'file.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'file.ts'))).toBe(false);
|
||||
@@ -53,9 +53,10 @@ describe('file-ops', () => {
|
||||
|
||||
it('delete success', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'remove-me.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'delete', path: 'remove-me.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'delete', path: 'remove-me.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'remove-me.ts'))).toBe(false);
|
||||
});
|
||||
@@ -63,43 +64,50 @@ describe('file-ops', () => {
|
||||
it('rename target exists produces error', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'a');
|
||||
fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'b');
|
||||
const result = executeFileOps([
|
||||
{ type: 'rename', from: 'a.ts', to: 'b.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'rename', from: 'a.ts', to: 'b.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('delete missing file produces warning not error', () => {
|
||||
const result = executeFileOps([
|
||||
{ type: 'delete', path: 'nonexistent.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'delete', path: 'nonexistent.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('move creates destination directory', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'move', from: 'src.ts', to: 'deep/nested/dir/src.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'move', from: 'src.ts', to: 'deep/nested/dir/src.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'deep', 'nested', 'dir', 'src.ts'))).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpDir, 'deep', 'nested', 'dir', 'src.ts')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('path escape produces error', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'file.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'rename', from: 'file.ts', to: '../../escaped.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'rename', from: 'file.ts', to: '../../escaped.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('source missing produces error for rename', () => {
|
||||
const result = executeFileOps([
|
||||
{ type: 'rename', from: 'missing.ts', to: 'new.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'rename', from: 'missing.ts', to: 'new.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -117,12 +125,15 @@ describe('file-ops', () => {
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'source.ts'), 'content');
|
||||
|
||||
const result = executeFileOps([
|
||||
{ type: 'move', from: 'source.ts', to: 'linkdir/pwned.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'move', from: 'source.ts', to: 'linkdir/pwned.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'source.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outsideDir, 'pwned.ts'))).toBe(false);
|
||||
|
||||
@@ -142,12 +153,15 @@ describe('file-ops', () => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = executeFileOps([
|
||||
{ type: 'delete', path: 'linkdir/victim.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'delete', path: 'linkdir/victim.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(outsideFile)).toBe(true);
|
||||
|
||||
cleanup(outsideDir);
|
||||
|
||||
@@ -53,75 +53,123 @@ describe('manifest', () => {
|
||||
it('throws on missing skill field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
version: '1.0.0', core_version: '1.0.0', adds: [], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing version field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', core_version: '1.0.0', adds: [], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing core_version field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', adds: [], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing adds field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0', modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing modifies field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0', adds: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on path traversal in adds', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0',
|
||||
adds: ['../etc/passwd'], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['../etc/passwd'],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow('Invalid path');
|
||||
});
|
||||
|
||||
it('throws on path traversal in modifies', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0',
|
||||
adds: [], modifies: ['../../secret.ts'],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: ['../../secret.ts'],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow('Invalid path');
|
||||
});
|
||||
|
||||
it('throws on absolute path in adds', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0',
|
||||
adds: ['/etc/passwd'], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['/etc/passwd'],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow('Invalid path');
|
||||
});
|
||||
|
||||
@@ -230,7 +278,9 @@ describe('manifest', () => {
|
||||
it('parses new optional fields (author, license, etc)', () => {
|
||||
const dir = path.join(tmpDir, 'full-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
@@ -241,7 +291,8 @@ describe('manifest', () => {
|
||||
min_skills_system_version: '0.1.0',
|
||||
tested_with: ['telegram', 'discord'],
|
||||
post_apply: ['echo done'],
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const manifest = readManifest(dir);
|
||||
expect(manifest.author).toBe('tester');
|
||||
expect(manifest.license).toBe('MIT');
|
||||
@@ -266,14 +317,17 @@ describe('manifest', () => {
|
||||
it('checkSystemVersion passes when engine is new enough', () => {
|
||||
const dir = path.join(tmpDir, 'sys-ok');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
min_skills_system_version: '0.1.0',
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const manifest = readManifest(dir);
|
||||
const result = checkSystemVersion(manifest);
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -282,14 +336,17 @@ describe('manifest', () => {
|
||||
it('checkSystemVersion fails when engine is too old', () => {
|
||||
const dir = path.join(tmpDir, 'sys-fail');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
min_skills_system_version: '99.0.0',
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const manifest = readManifest(dir);
|
||||
const result = checkSystemVersion(manifest);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
@@ -2,7 +2,11 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadPathRemap, recordPathRemap, resolvePathRemap } from '../path-remap.js';
|
||||
import {
|
||||
loadPathRemap,
|
||||
recordPathRemap,
|
||||
resolvePathRemap,
|
||||
} from '../path-remap.js';
|
||||
import { readState, writeState } from '../state.js';
|
||||
import {
|
||||
cleanup,
|
||||
|
||||
@@ -313,10 +313,7 @@ describe('rebase', () => {
|
||||
// Set up current base — short file so changes overlap
|
||||
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
|
||||
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(baseDir, 'src', 'index.ts'),
|
||||
'const x = 1;\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(baseDir, 'src', 'index.ts'), 'const x = 1;\n');
|
||||
|
||||
// Working tree: skill replaces the same line
|
||||
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
|
||||
|
||||
@@ -95,9 +95,7 @@ describe('replay', () => {
|
||||
expect(result.perSkill.telegram.success).toBe(true);
|
||||
|
||||
// Added file should exist
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(true);
|
||||
expect(
|
||||
fs.readFileSync(path.join(tmpDir, 'src', 'telegram.ts'), 'utf-8'),
|
||||
).toBe('telegram code\n');
|
||||
@@ -134,7 +132,8 @@ describe('replay', () => {
|
||||
modifies: ['src/config.ts'],
|
||||
addFiles: { 'src/telegram.ts': 'tg code' },
|
||||
modifyFiles: {
|
||||
'src/config.ts': 'telegram import\nline1\nline2\nline3\nline4\nline5\n',
|
||||
'src/config.ts':
|
||||
'telegram import\nline1\nline2\nline3\nline4\nline5\n',
|
||||
},
|
||||
dirName: 'skill-pkg-tg',
|
||||
});
|
||||
@@ -148,7 +147,8 @@ describe('replay', () => {
|
||||
modifies: ['src/config.ts'],
|
||||
addFiles: { 'src/discord.ts': 'dc code' },
|
||||
modifyFiles: {
|
||||
'src/config.ts': 'line1\nline2\nline3\nline4\nline5\ndiscord import\n',
|
||||
'src/config.ts':
|
||||
'line1\nline2\nline3\nline4\nline5\ndiscord import\n',
|
||||
},
|
||||
dirName: 'skill-pkg-dc',
|
||||
});
|
||||
@@ -164,12 +164,8 @@ describe('replay', () => {
|
||||
expect(result.perSkill.discord.success).toBe(true);
|
||||
|
||||
// Both added files should exist
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'discord.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'discord.ts'))).toBe(true);
|
||||
|
||||
// Config should have both changes
|
||||
const config = fs.readFileSync(
|
||||
@@ -226,7 +222,11 @@ describe('replay', () => {
|
||||
|
||||
const result = await replaySkills({
|
||||
skills: ['skill-a', 'skill-b', 'skill-c'],
|
||||
skillDirs: { 'skill-a': skill1Dir, 'skill-b': skill2Dir, 'skill-c': skill3Dir },
|
||||
skillDirs: {
|
||||
'skill-a': skill1Dir,
|
||||
'skill-b': skill2Dir,
|
||||
'skill-c': skill3Dir,
|
||||
},
|
||||
projectRoot: tmpDir,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,11 +32,12 @@ describe('run-migrations', () => {
|
||||
to: string,
|
||||
): { stdout: string; exitCode: number } {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
tsxBin,
|
||||
[scriptPath, from, to, newCoreDir],
|
||||
{ cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
|
||||
);
|
||||
const stdout = execFileSync(tsxBin, [scriptPath, from, to, newCoreDir], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { stdout, exitCode: 0 };
|
||||
} catch (err: any) {
|
||||
return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 };
|
||||
|
||||
@@ -66,7 +66,9 @@ describe('state', () => {
|
||||
expect(state.applied_skills).toHaveLength(1);
|
||||
expect(state.applied_skills[0].name).toBe('my-skill');
|
||||
expect(state.applied_skills[0].version).toBe('1.0.0');
|
||||
expect(state.applied_skills[0].file_hashes).toEqual({ 'src/foo.ts': 'abc123' });
|
||||
expect(state.applied_skills[0].file_hashes).toEqual({
|
||||
'src/foo.ts': 'abc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('re-applying same skill replaces it', () => {
|
||||
|
||||
@@ -68,10 +68,17 @@ describe('structured', () => {
|
||||
describe('mergeNpmDependencies', () => {
|
||||
it('adds new dependencies', () => {
|
||||
const pkgPath = path.join(tmpDir, 'package.json');
|
||||
fs.writeFileSync(pkgPath, JSON.stringify({
|
||||
fs.writeFileSync(
|
||||
pkgPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'test',
|
||||
dependencies: { existing: '^1.0.0' },
|
||||
}, null, 2));
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
mergeNpmDependencies(pkgPath, { newdep: '^2.0.0' });
|
||||
|
||||
@@ -82,10 +89,17 @@ describe('structured', () => {
|
||||
|
||||
it('resolves compatible ^ ranges', () => {
|
||||
const pkgPath = path.join(tmpDir, 'package.json');
|
||||
fs.writeFileSync(pkgPath, JSON.stringify({
|
||||
fs.writeFileSync(
|
||||
pkgPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'test',
|
||||
dependencies: { dep: '^1.0.0' },
|
||||
}, null, 2));
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
mergeNpmDependencies(pkgPath, { dep: '^1.1.0' });
|
||||
|
||||
@@ -95,11 +109,18 @@ describe('structured', () => {
|
||||
|
||||
it('sorts devDependencies after merge', () => {
|
||||
const pkgPath = path.join(tmpDir, 'package.json');
|
||||
fs.writeFileSync(pkgPath, JSON.stringify({
|
||||
fs.writeFileSync(
|
||||
pkgPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'test',
|
||||
dependencies: {},
|
||||
devDependencies: { zlib: '^1.0.0', acorn: '^2.0.0' },
|
||||
}, null, 2));
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
mergeNpmDependencies(pkgPath, { middle: '^1.0.0' });
|
||||
|
||||
@@ -110,10 +131,17 @@ describe('structured', () => {
|
||||
|
||||
it('throws on incompatible major versions', () => {
|
||||
const pkgPath = path.join(tmpDir, 'package.json');
|
||||
fs.writeFileSync(pkgPath, JSON.stringify({
|
||||
fs.writeFileSync(
|
||||
pkgPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'test',
|
||||
dependencies: { dep: '^1.0.0' },
|
||||
}, null, 2));
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
expect(() => mergeNpmDependencies(pkgPath, { dep: '^2.0.0' })).toThrow();
|
||||
});
|
||||
@@ -170,7 +198,10 @@ describe('structured', () => {
|
||||
describe('mergeDockerComposeServices', () => {
|
||||
it('adds new services', () => {
|
||||
const composePath = path.join(tmpDir, 'docker-compose.yaml');
|
||||
fs.writeFileSync(composePath, 'version: "3"\nservices:\n web:\n image: nginx\n');
|
||||
fs.writeFileSync(
|
||||
composePath,
|
||||
'version: "3"\nservices:\n web:\n image: nginx\n',
|
||||
);
|
||||
|
||||
mergeDockerComposeServices(composePath, {
|
||||
redis: { image: 'redis:7' },
|
||||
@@ -182,7 +213,10 @@ describe('structured', () => {
|
||||
|
||||
it('skips existing services', () => {
|
||||
const composePath = path.join(tmpDir, 'docker-compose.yaml');
|
||||
fs.writeFileSync(composePath, 'version: "3"\nservices:\n web:\n image: nginx\n');
|
||||
fs.writeFileSync(
|
||||
composePath,
|
||||
'version: "3"\nservices:\n web:\n image: nginx\n',
|
||||
);
|
||||
|
||||
mergeDockerComposeServices(composePath, {
|
||||
web: { image: 'apache' },
|
||||
@@ -194,11 +228,16 @@ describe('structured', () => {
|
||||
|
||||
it('throws on port collision', () => {
|
||||
const composePath = path.join(tmpDir, 'docker-compose.yaml');
|
||||
fs.writeFileSync(composePath, 'version: "3"\nservices:\n web:\n image: nginx\n ports:\n - "8080:80"\n');
|
||||
fs.writeFileSync(
|
||||
composePath,
|
||||
'version: "3"\nservices:\n web:\n image: nginx\n ports:\n - "8080:80"\n',
|
||||
);
|
||||
|
||||
expect(() => mergeDockerComposeServices(composePath, {
|
||||
expect(() =>
|
||||
mergeDockerComposeServices(composePath, {
|
||||
api: { image: 'node', ports: ['8080:3000'] },
|
||||
})).toThrow();
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ export function createTempDir(): string {
|
||||
}
|
||||
|
||||
export function setupNanoclawDir(tmpDir: string): void {
|
||||
fs.mkdirSync(path.join(tmpDir, '.nanoclaw', 'base', 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.nanoclaw', 'base', 'src'), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(path.join(tmpDir, '.nanoclaw', 'backup'), { recursive: true });
|
||||
}
|
||||
|
||||
@@ -26,7 +28,9 @@ export function createMinimalState(tmpDir: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function createSkillPackage(tmpDir: string, opts: {
|
||||
export function createSkillPackage(
|
||||
tmpDir: string,
|
||||
opts: {
|
||||
skill?: string;
|
||||
version?: string;
|
||||
core_version?: string;
|
||||
@@ -42,7 +46,8 @@ export function createSkillPackage(tmpDir: string, opts: {
|
||||
post_apply?: string[];
|
||||
min_skills_system_version?: string;
|
||||
dirName?: string;
|
||||
}): string {
|
||||
},
|
||||
): string {
|
||||
const skillDir = path.join(tmpDir, opts.dirName ?? 'skill-pkg');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
|
||||
@@ -60,7 +65,8 @@ export function createSkillPackage(tmpDir: string, opts: {
|
||||
file_ops: opts.file_ops,
|
||||
};
|
||||
if (opts.post_apply) manifest.post_apply = opts.post_apply;
|
||||
if (opts.min_skills_system_version) manifest.min_skills_system_version = opts.min_skills_system_version;
|
||||
if (opts.min_skills_system_version)
|
||||
manifest.min_skills_system_version = opts.min_skills_system_version;
|
||||
|
||||
fs.writeFileSync(path.join(skillDir, 'manifest.yaml'), stringify(manifest));
|
||||
|
||||
@@ -87,7 +93,10 @@ export function createSkillPackage(tmpDir: string, opts: {
|
||||
|
||||
export function initGitRepo(dir: string): void {
|
||||
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', {
|
||||
cwd: dir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
||||
execSync('git config rerere.enabled true', { cwd: dir, stdio: 'pipe' });
|
||||
fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules\n');
|
||||
|
||||
@@ -203,16 +203,14 @@ describe('uninstall', () => {
|
||||
setupSkillPackage('telegram', {
|
||||
adds: { 'src/telegram.ts': 'tg code\n' },
|
||||
modifies: {
|
||||
'src/config.ts':
|
||||
'telegram import\nline1\nline2\nline3\nline4\nline5\n',
|
||||
'src/config.ts': 'telegram import\nline1\nline2\nline3\nline4\nline5\n',
|
||||
},
|
||||
});
|
||||
|
||||
setupSkillPackage('discord', {
|
||||
adds: { 'src/discord.ts': 'dc code\n' },
|
||||
modifies: {
|
||||
'src/config.ts':
|
||||
'line1\nline2\nline3\nline4\nline5\ndiscord import\n',
|
||||
'src/config.ts': 'line1\nline2\nline3\nline4\nline5\ndiscord import\n',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import { cleanup, createTempDir, initGitRepo, setupNanoclawDir } from './test-helpers.js';
|
||||
import {
|
||||
cleanup,
|
||||
createTempDir,
|
||||
initGitRepo,
|
||||
setupNanoclawDir,
|
||||
} from './test-helpers.js';
|
||||
|
||||
describe('update-core.ts CLI flags', () => {
|
||||
let tmpDir: string;
|
||||
@@ -101,11 +106,12 @@ describe('update-core.ts CLI flags', () => {
|
||||
'package.json': JSON.stringify({ version: '2.0.0' }),
|
||||
});
|
||||
|
||||
const stdout = execFileSync(
|
||||
tsxBin,
|
||||
[scriptPath, '--json', newCoreDir],
|
||||
{ cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
|
||||
);
|
||||
const stdout = execFileSync(tsxBin, [scriptPath, '--json', newCoreDir], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout);
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import { cleanup, createTempDir, initGitRepo, setupNanoclawDir } from './test-helpers.js';
|
||||
import {
|
||||
cleanup,
|
||||
createTempDir,
|
||||
initGitRepo,
|
||||
setupNanoclawDir,
|
||||
} from './test-helpers.js';
|
||||
|
||||
let tmpDir: string;
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
} from './manifest.js';
|
||||
import { loadPathRemap, resolvePathRemap } from './path-remap.js';
|
||||
import { mergeFile } from './merge.js';
|
||||
import { computeFileHash, readState, recordSkillApplication, writeState } from './state.js';
|
||||
import {
|
||||
computeFileHash,
|
||||
readState,
|
||||
recordSkillApplication,
|
||||
writeState,
|
||||
} from './state.js';
|
||||
import {
|
||||
mergeDockerComposeServices,
|
||||
mergeEnvAdditions,
|
||||
@@ -116,11 +121,17 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
try {
|
||||
// --- Backup ---
|
||||
const filesToBackup = [
|
||||
...manifest.modifies.map((f) => path.join(projectRoot, resolvePathRemap(f, pathRemap))),
|
||||
...manifest.adds.map((f) => path.join(projectRoot, resolvePathRemap(f, pathRemap))),
|
||||
...manifest.modifies.map((f) =>
|
||||
path.join(projectRoot, resolvePathRemap(f, pathRemap)),
|
||||
),
|
||||
...manifest.adds.map((f) =>
|
||||
path.join(projectRoot, resolvePathRemap(f, pathRemap)),
|
||||
),
|
||||
...(manifest.file_ops || [])
|
||||
.filter((op) => op.from)
|
||||
.map((op) => path.join(projectRoot, resolvePathRemap(op.from!, pathRemap))),
|
||||
.map((op) =>
|
||||
path.join(projectRoot, resolvePathRemap(op.from!, pathRemap)),
|
||||
),
|
||||
path.join(projectRoot, 'package.json'),
|
||||
path.join(projectRoot, 'package-lock.json'),
|
||||
path.join(projectRoot, '.env.example'),
|
||||
@@ -167,7 +178,12 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
for (const relPath of manifest.modifies) {
|
||||
const resolvedPath = resolvePathRemap(relPath, pathRemap);
|
||||
const currentPath = path.join(projectRoot, resolvedPath);
|
||||
const basePath = path.join(projectRoot, NANOCLAW_DIR, 'base', resolvedPath);
|
||||
const basePath = path.join(
|
||||
projectRoot,
|
||||
NANOCLAW_DIR,
|
||||
'base',
|
||||
resolvedPath,
|
||||
);
|
||||
// skillPath uses original relPath — skill packages are never mutated
|
||||
const skillPath = path.join(skillDir, 'modify', relPath);
|
||||
|
||||
@@ -259,7 +275,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
for (const f of addedFiles) {
|
||||
try {
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
restoreBackup();
|
||||
clearBackup();
|
||||
@@ -311,7 +329,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
for (const f of addedFiles) {
|
||||
try {
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
restoreBackup();
|
||||
// Re-read state and remove the skill we just recorded
|
||||
@@ -345,7 +365,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
for (const f of addedFiles) {
|
||||
try {
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
restoreBackup();
|
||||
clearBackup();
|
||||
@@ -354,4 +376,3 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,4 +8,9 @@ export const SKILLS_SCHEMA_VERSION = '0.1.0';
|
||||
|
||||
// Top-level paths to include in base snapshot and upstream extraction.
|
||||
// Add new entries here when new root-level directories/files need tracking.
|
||||
export const BASE_INCLUDES = ['src/', 'package.json', '.env.example', 'container/'];
|
||||
export const BASE_INCLUDES = [
|
||||
'src/',
|
||||
'package.json',
|
||||
'.env.example',
|
||||
'container/',
|
||||
];
|
||||
|
||||
@@ -5,7 +5,11 @@ import path from 'path';
|
||||
import { parse, stringify } from 'yaml';
|
||||
|
||||
import { BASE_DIR, CUSTOM_DIR } from './constants.js';
|
||||
import { computeFileHash, readState, recordCustomModification } from './state.js';
|
||||
import {
|
||||
computeFileHash,
|
||||
readState,
|
||||
recordCustomModification,
|
||||
} from './state.js';
|
||||
|
||||
interface PendingCustomize {
|
||||
description: string;
|
||||
@@ -76,7 +80,9 @@ export function commitCustomize(): void {
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No files changed during customize session. Nothing to commit.');
|
||||
console.log(
|
||||
'No files changed during customize session. Nothing to commit.',
|
||||
);
|
||||
fs.unlinkSync(pendingPath);
|
||||
return;
|
||||
}
|
||||
@@ -104,7 +110,9 @@ export function commitCustomize(): void {
|
||||
// diff exits 1 when files differ — that's expected
|
||||
combinedPatch += execErr.stdout;
|
||||
} else if (execErr.status === 2) {
|
||||
throw new Error(`diff error for ${relativePath}: diff exited with status 2 (check file permissions or encoding)`);
|
||||
throw new Error(
|
||||
`diff error for ${relativePath}: diff exited with status 2 (check file permissions or encoding)`,
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ function nearestExistingPathOrSymlink(candidateAbsPath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRealPathWithSymlinkAwareAnchor(candidateAbsPath: string): string {
|
||||
function resolveRealPathWithSymlinkAwareAnchor(
|
||||
candidateAbsPath: string,
|
||||
): string {
|
||||
const anchorPath = nearestExistingPathOrSymlink(candidateAbsPath);
|
||||
const anchorStat = fs.lstatSync(anchorPath);
|
||||
let realAnchor: string;
|
||||
@@ -56,7 +58,9 @@ function safePath(projectRoot: string, relativePath: string): string | null {
|
||||
}
|
||||
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const realParent = resolveRealPathWithSymlinkAwareAnchor(path.dirname(resolved));
|
||||
const realParent = resolveRealPathWithSymlinkAwareAnchor(
|
||||
path.dirname(resolved),
|
||||
);
|
||||
if (!isWithinRoot(realRoot, realParent)) {
|
||||
return null;
|
||||
}
|
||||
@@ -64,7 +68,10 @@ function safePath(projectRoot: string, relativePath: string): string | null {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function executeFileOps(ops: FileOperation[], projectRoot: string): FileOpsResult {
|
||||
export function executeFileOps(
|
||||
ops: FileOperation[],
|
||||
projectRoot: string,
|
||||
): FileOpsResult {
|
||||
const result: FileOpsResult = {
|
||||
success: true,
|
||||
executed: [],
|
||||
@@ -122,7 +129,9 @@ export function executeFileOps(ops: FileOperation[], projectRoot: string): FileO
|
||||
return result;
|
||||
}
|
||||
if (!fs.existsSync(delPath)) {
|
||||
result.warnings.push(`delete: file does not exist (skipped): ${op.path}`);
|
||||
result.warnings.push(
|
||||
`delete: file does not exist (skipped): ${op.path}`,
|
||||
);
|
||||
result.executed.push(op);
|
||||
break;
|
||||
}
|
||||
@@ -169,7 +178,9 @@ export function executeFileOps(ops: FileOperation[], projectRoot: string): FileO
|
||||
}
|
||||
|
||||
default: {
|
||||
result.errors.push(`unknown operation type: ${(op as FileOperation).type}`);
|
||||
result.errors.push(
|
||||
`unknown operation type: ${(op as FileOperation).type}`,
|
||||
);
|
||||
result.success = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ export {
|
||||
checkSystemVersion,
|
||||
readManifest,
|
||||
} from './manifest.js';
|
||||
export {
|
||||
isGitRepo,
|
||||
mergeFile,
|
||||
} from './merge.js';
|
||||
export { isGitRepo, mergeFile } from './merge.js';
|
||||
export {
|
||||
loadPathRemap,
|
||||
recordPathRemap,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { BACKUP_DIR, BASE_DIR, BASE_INCLUDES, NANOCLAW_DIR } from './constants.js';
|
||||
import {
|
||||
BACKUP_DIR,
|
||||
BASE_DIR,
|
||||
BASE_INCLUDES,
|
||||
NANOCLAW_DIR,
|
||||
} from './constants.js';
|
||||
import { isGitRepo } from './merge.js';
|
||||
import { writeState } from './state.js';
|
||||
import { SkillState } from './types.js';
|
||||
@@ -68,11 +73,7 @@ export function initNanoclawDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirFiltered(
|
||||
src: string,
|
||||
dest: string,
|
||||
excludes: string[],
|
||||
): void {
|
||||
function copyDirFiltered(src: string, dest: string, excludes: string[]): void {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
|
||||
@@ -40,9 +40,7 @@ export function acquireLock(): () => void {
|
||||
} catch {
|
||||
// Lock file exists — check if it's stale or from a dead process
|
||||
try {
|
||||
const existing: LockInfo = JSON.parse(
|
||||
fs.readFileSync(lockPath, 'utf-8'),
|
||||
);
|
||||
const existing: LockInfo = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
if (!isStale(existing) && isProcessAlive(existing.pid)) {
|
||||
throw new Error(
|
||||
`Operation in progress (pid ${existing.pid}, started ${new Date(existing.timestamp).toISOString()}). If this is stale, delete ${LOCK_FILE}`,
|
||||
@@ -59,11 +57,17 @@ export function acquireLock(): () => void {
|
||||
// Corrupt or unreadable — overwrite
|
||||
}
|
||||
|
||||
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lockInfo), { flag: 'wx' });
|
||||
} catch {
|
||||
throw new Error('Lock contention: another process acquired the lock. Retry.');
|
||||
throw new Error(
|
||||
'Lock contention: another process acquired the lock. Retry.',
|
||||
);
|
||||
}
|
||||
return () => releaseLock();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ export function readManifest(skillDir: string): SkillManifest {
|
||||
const allPaths = [...manifest.adds, ...manifest.modifies];
|
||||
for (const p of allPaths) {
|
||||
if (p.includes('..') || path.isAbsolute(p)) {
|
||||
throw new Error(`Invalid path in manifest: ${p} (must be relative without "..")`);
|
||||
throw new Error(
|
||||
`Invalid path in manifest: ${p} (must be relative without "..")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +80,10 @@ export function checkSystemVersion(manifest: SkillManifest): {
|
||||
if (!manifest.min_skills_system_version) {
|
||||
return { ok: true };
|
||||
}
|
||||
const cmp = compareSemver(manifest.min_skills_system_version, SKILLS_SCHEMA_VERSION);
|
||||
const cmp = compareSemver(
|
||||
manifest.min_skills_system_version,
|
||||
SKILLS_SCHEMA_VERSION,
|
||||
);
|
||||
if (cmp > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -42,11 +42,7 @@ export function migrateExisting(): void {
|
||||
|
||||
if (diff.trim()) {
|
||||
fs.mkdirSync(customDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, patchRelPath),
|
||||
diff,
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(path.join(projectRoot, patchRelPath), diff, 'utf-8');
|
||||
|
||||
// Extract modified file paths from the diff
|
||||
const filesModified = [...diff.matchAll(/^diff -ruN .+ (.+)$/gm)]
|
||||
|
||||
@@ -34,10 +34,7 @@ function toSafeProjectRelativePath(
|
||||
const root = path.resolve(projectRoot);
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const resolved = path.resolve(root, candidatePath);
|
||||
if (
|
||||
!resolved.startsWith(root + path.sep) &&
|
||||
resolved !== root
|
||||
) {
|
||||
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
|
||||
throw new Error(`Path remap escapes project root: "${candidatePath}"`);
|
||||
}
|
||||
if (resolved === root) {
|
||||
@@ -99,9 +96,7 @@ export function resolvePathRemap(
|
||||
): string {
|
||||
const projectRoot = process.cwd();
|
||||
const safeRelPath = toSafeProjectRelativePath(relPath, projectRoot);
|
||||
const remapped =
|
||||
remap[safeRelPath] ??
|
||||
remap[relPath];
|
||||
const remapped = remap[safeRelPath] ?? remap[relPath];
|
||||
|
||||
if (remapped === undefined) {
|
||||
return safeRelPath;
|
||||
|
||||
@@ -27,9 +27,7 @@ function walkDir(dir: string, root: string): string[] {
|
||||
return results;
|
||||
}
|
||||
|
||||
function collectTrackedFiles(
|
||||
state: ReturnType<typeof readState>,
|
||||
): Set<string> {
|
||||
function collectTrackedFiles(state: ReturnType<typeof readState>): Set<string> {
|
||||
const tracked = new Set<string>();
|
||||
|
||||
for (const skill of state.applied_skills) {
|
||||
@@ -119,11 +117,7 @@ export async function rebase(newBasePath?: string): Promise<RebaseResult> {
|
||||
}
|
||||
|
||||
// Save combined patch
|
||||
const patchPath = path.join(
|
||||
projectRoot,
|
||||
NANOCLAW_DIR,
|
||||
'combined.patch',
|
||||
);
|
||||
const patchPath = path.join(projectRoot, NANOCLAW_DIR, 'combined.patch');
|
||||
fs.writeFileSync(patchPath, combinedPatch, 'utf-8');
|
||||
|
||||
if (newBasePath) {
|
||||
|
||||
@@ -4,7 +4,11 @@ import path from 'path';
|
||||
|
||||
import { parse, stringify } from 'yaml';
|
||||
|
||||
import { SKILLS_SCHEMA_VERSION, NANOCLAW_DIR, STATE_FILE } from './constants.js';
|
||||
import {
|
||||
SKILLS_SCHEMA_VERSION,
|
||||
NANOCLAW_DIR,
|
||||
STATE_FILE,
|
||||
} from './constants.js';
|
||||
import { AppliedSkill, CustomModification, SkillState } from './types.js';
|
||||
|
||||
function getStatePath(): string {
|
||||
|
||||
@@ -94,7 +94,9 @@ export function mergeNpmDependencies(
|
||||
|
||||
if (pkg.devDependencies) {
|
||||
pkg.devDependencies = Object.fromEntries(
|
||||
Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b)),
|
||||
Object.entries(pkg.devDependencies).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,5 +194,8 @@ export function mergeDockerComposeServices(
|
||||
}
|
||||
|
||||
export function runNpmInstall(): void {
|
||||
execSync('npm install --legacy-peer-deps', { stdio: 'inherit', cwd: process.cwd() });
|
||||
execSync('npm install --legacy-peer-deps', {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,9 +229,16 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
|
||||
}
|
||||
|
||||
// --- Record path remaps from update metadata ---
|
||||
const remapFile = path.join(newCorePath, '.nanoclaw-meta', 'path_remap.yaml');
|
||||
const remapFile = path.join(
|
||||
newCorePath,
|
||||
'.nanoclaw-meta',
|
||||
'path_remap.yaml',
|
||||
);
|
||||
if (fs.existsSync(remapFile)) {
|
||||
const remap = parseYaml(fs.readFileSync(remapFile, 'utf-8')) as Record<string, string>;
|
||||
const remap = parseYaml(fs.readFileSync(remapFile, 'utf-8')) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
if (remap && typeof remap === 'object') {
|
||||
recordPathRemap(remap);
|
||||
}
|
||||
@@ -251,11 +258,16 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
|
||||
let hasNpmDeps = false;
|
||||
|
||||
for (const skill of state.applied_skills) {
|
||||
const outcomes = skill.structured_outcomes as Record<string, unknown> | undefined;
|
||||
const outcomes = skill.structured_outcomes as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!outcomes) continue;
|
||||
|
||||
if (outcomes.npm_dependencies) {
|
||||
Object.assign(allNpmDeps, outcomes.npm_dependencies as Record<string, string>);
|
||||
Object.assign(
|
||||
allNpmDeps,
|
||||
outcomes.npm_dependencies as Record<string, string>,
|
||||
);
|
||||
hasNpmDeps = true;
|
||||
}
|
||||
if (outcomes.env_additions) {
|
||||
@@ -292,7 +304,9 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
|
||||
const skillReapplyResults: Record<string, boolean> = {};
|
||||
|
||||
for (const skill of state.applied_skills) {
|
||||
const outcomes = skill.structured_outcomes as Record<string, unknown> | undefined;
|
||||
const outcomes = skill.structured_outcomes as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!outcomes?.test) continue;
|
||||
|
||||
const testCmd = outcomes.test as string;
|
||||
@@ -339,4 +353,3 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,9 @@ vi.mock('@whiskeysockets/baileys', () => {
|
||||
timedOut: 408,
|
||||
restartRequired: 515,
|
||||
},
|
||||
fetchLatestWaWebVersion: vi.fn().mockResolvedValue({ version: [2, 3000, 0] }),
|
||||
fetchLatestWaWebVersion: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ version: [2, 3000, 0] }),
|
||||
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
|
||||
useMultiFileAuthState: vi.fn().mockResolvedValue({
|
||||
state: {
|
||||
@@ -101,7 +103,9 @@ import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
function createTestOpts(overrides?: Partial<WhatsAppChannelOpts>): WhatsAppChannelOpts {
|
||||
function createTestOpts(
|
||||
overrides?: Partial<WhatsAppChannelOpts>,
|
||||
): WhatsAppChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
@@ -168,13 +172,17 @@ describe('WhatsAppChannel', () => {
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
await connectChannel(channel);
|
||||
|
||||
const { fetchLatestWaWebVersion } = await import('@whiskeysockets/baileys');
|
||||
const { fetchLatestWaWebVersion } =
|
||||
await import('@whiskeysockets/baileys');
|
||||
expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('falls back gracefully when version fetch fails', async () => {
|
||||
const { fetchLatestWaWebVersion } = await import('@whiskeysockets/baileys');
|
||||
vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce(new Error('network error'));
|
||||
const { fetchLatestWaWebVersion } =
|
||||
await import('@whiskeysockets/baileys');
|
||||
vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce(
|
||||
new Error('network error'),
|
||||
);
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
@@ -226,10 +234,9 @@ describe('WhatsAppChannel', () => {
|
||||
await (channel as any).flushOutgoingQueue();
|
||||
|
||||
// Group messages get prefixed when flushed
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith(
|
||||
'test@g.us',
|
||||
{ text: 'Andy: Queued message' },
|
||||
);
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', {
|
||||
text: 'Andy: Queued message',
|
||||
});
|
||||
});
|
||||
|
||||
it('disconnects cleanly', async () => {
|
||||
@@ -249,7 +256,9 @@ describe('WhatsAppChannel', () => {
|
||||
describe('authentication', () => {
|
||||
it('exits process when QR code is emitted (no auth state)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
const mockExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
@@ -291,7 +300,9 @@ describe('WhatsAppChannel', () => {
|
||||
});
|
||||
|
||||
it('exits on loggedOut disconnect', async () => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
const mockExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
const opts = createTestOpts();
|
||||
const channel = new WhatsAppChannel(opts);
|
||||
@@ -477,7 +488,10 @@ describe('WhatsAppChannel', () => {
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
imageMessage: { caption: 'Check this photo', mimetype: 'image/jpeg' },
|
||||
imageMessage: {
|
||||
caption: 'Check this photo',
|
||||
mimetype: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
pushName: 'Diana',
|
||||
messageTimestamp: Math.floor(Date.now() / 1000),
|
||||
@@ -684,7 +698,9 @@ describe('WhatsAppChannel', () => {
|
||||
|
||||
await channel.sendMessage('test@g.us', 'Hello');
|
||||
// Group messages get prefixed with assistant name
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', { text: 'Andy: Hello' });
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', {
|
||||
text: 'Andy: Hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefixes direct chat messages on shared number', async () => {
|
||||
@@ -695,7 +711,10 @@ describe('WhatsAppChannel', () => {
|
||||
|
||||
await channel.sendMessage('123@s.whatsapp.net', 'Hello');
|
||||
// Shared number: DMs also get prefixed (needed for self-chat distinction)
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith('123@s.whatsapp.net', { text: 'Andy: Hello' });
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledWith(
|
||||
'123@s.whatsapp.net',
|
||||
{ text: 'Andy: Hello' },
|
||||
);
|
||||
});
|
||||
|
||||
it('queues message when disconnected', async () => {
|
||||
@@ -739,9 +758,15 @@ describe('WhatsAppChannel', () => {
|
||||
|
||||
expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3);
|
||||
// Group messages get prefixed
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', { text: 'Andy: First' });
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', { text: 'Andy: Second' });
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', { text: 'Andy: Third' });
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', {
|
||||
text: 'Andy: First',
|
||||
});
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', {
|
||||
text: 'Andy: Second',
|
||||
});
|
||||
expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', {
|
||||
text: 'Andy: Third',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -874,7 +899,10 @@ describe('WhatsAppChannel', () => {
|
||||
await connectChannel(channel);
|
||||
|
||||
await channel.setTyping('test@g.us', true);
|
||||
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('composing', 'test@g.us');
|
||||
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith(
|
||||
'composing',
|
||||
'test@g.us',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends paused presence when stopping', async () => {
|
||||
@@ -884,7 +912,10 @@ describe('WhatsAppChannel', () => {
|
||||
await connectChannel(channel);
|
||||
|
||||
await channel.setTyping('test@g.us', false);
|
||||
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith('paused', 'test@g.us');
|
||||
expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith(
|
||||
'paused',
|
||||
'test@g.us',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles typing indicator failure gracefully', async () => {
|
||||
@@ -896,7 +927,9 @@ describe('WhatsAppChannel', () => {
|
||||
fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed'));
|
||||
|
||||
// Should not throw
|
||||
await expect(channel.setTyping('test@g.us', true)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
channel.setTyping('test@g.us', true),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,14 +11,19 @@ import makeWASocket, {
|
||||
useMultiFileAuthState,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, STORE_DIR } from '../config.js';
|
||||
import {
|
||||
getLastGroupSync,
|
||||
setLastGroupSync,
|
||||
updateChatName,
|
||||
} from '../db.js';
|
||||
ASSISTANT_HAS_OWN_NUMBER,
|
||||
ASSISTANT_NAME,
|
||||
STORE_DIR,
|
||||
} from '../config.js';
|
||||
import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js';
|
||||
import {
|
||||
Channel,
|
||||
OnInboundMessage,
|
||||
OnChatMetadata,
|
||||
RegisteredGroup,
|
||||
} from '../types.js';
|
||||
|
||||
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
@@ -57,7 +62,10 @@ export class WhatsAppChannel implements Channel {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
||||
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||
logger.warn({ err }, 'Failed to fetch latest WA Web version, using default');
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Failed to fetch latest WA Web version, using default',
|
||||
);
|
||||
return { version: undefined };
|
||||
});
|
||||
this.sock = makeWASocket({
|
||||
@@ -86,9 +94,18 @@ export class WhatsAppChannel implements Channel {
|
||||
|
||||
if (connection === 'close') {
|
||||
this.connected = false;
|
||||
const reason = (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode;
|
||||
const reason = (
|
||||
lastDisconnect?.error as { output?: { statusCode?: number } }
|
||||
)?.output?.statusCode;
|
||||
const shouldReconnect = reason !== DisconnectReason.loggedOut;
|
||||
logger.info({ reason, shouldReconnect, queuedMessages: this.outgoingQueue.length }, 'Connection closed');
|
||||
logger.info(
|
||||
{
|
||||
reason,
|
||||
shouldReconnect,
|
||||
queuedMessages: this.outgoingQueue.length,
|
||||
},
|
||||
'Connection closed',
|
||||
);
|
||||
|
||||
if (shouldReconnect) {
|
||||
logger.info('Reconnecting...');
|
||||
@@ -167,7 +184,13 @@ export class WhatsAppChannel implements Channel {
|
||||
|
||||
// Always notify about chat metadata for group discovery
|
||||
const isGroup = chatJid.endsWith('@g.us');
|
||||
this.opts.onChatMetadata(chatJid, timestamp, undefined, 'whatsapp', isGroup);
|
||||
this.opts.onChatMetadata(
|
||||
chatJid,
|
||||
timestamp,
|
||||
undefined,
|
||||
'whatsapp',
|
||||
isGroup,
|
||||
);
|
||||
|
||||
// Only deliver full message for registered groups
|
||||
const groups = this.opts.registeredGroups();
|
||||
@@ -220,7 +243,10 @@ export class WhatsAppChannel implements Channel {
|
||||
|
||||
if (!this.connected) {
|
||||
this.outgoingQueue.push({ jid, text: prefixed });
|
||||
logger.info({ jid, length: prefixed.length, queueSize: this.outgoingQueue.length }, 'WA disconnected, message queued');
|
||||
logger.info(
|
||||
{ jid, length: prefixed.length, queueSize: this.outgoingQueue.length },
|
||||
'WA disconnected, message queued',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -229,7 +255,10 @@ export class WhatsAppChannel implements Channel {
|
||||
} catch (err) {
|
||||
// If send fails, queue it for retry on reconnect
|
||||
this.outgoingQueue.push({ jid, text: prefixed });
|
||||
logger.warn({ jid, err, queueSize: this.outgoingQueue.length }, 'Failed to send, message queued');
|
||||
logger.warn(
|
||||
{ jid, err, queueSize: this.outgoingQueue.length },
|
||||
'Failed to send, message queued',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +328,10 @@ export class WhatsAppChannel implements Channel {
|
||||
// Check local cache first
|
||||
const cached = this.lidToPhoneMap[lidUser];
|
||||
if (cached) {
|
||||
logger.debug({ lidJid: jid, phoneJid: cached }, 'Translated LID to phone JID (cached)');
|
||||
logger.debug(
|
||||
{ lidJid: jid, phoneJid: cached },
|
||||
'Translated LID to phone JID (cached)',
|
||||
);
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -309,7 +341,10 @@ export class WhatsAppChannel implements Channel {
|
||||
if (pn) {
|
||||
const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
|
||||
this.lidToPhoneMap[lidUser] = phoneJid;
|
||||
logger.info({ lidJid: jid, phoneJid }, 'Translated LID to phone JID (signalRepository)');
|
||||
logger.info(
|
||||
{ lidJid: jid, phoneJid },
|
||||
'Translated LID to phone JID (signalRepository)',
|
||||
);
|
||||
return phoneJid;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -323,12 +358,18 @@ export class WhatsAppChannel implements Channel {
|
||||
if (this.flushing || this.outgoingQueue.length === 0) return;
|
||||
this.flushing = true;
|
||||
try {
|
||||
logger.info({ count: this.outgoingQueue.length }, 'Flushing outgoing message queue');
|
||||
logger.info(
|
||||
{ count: this.outgoingQueue.length },
|
||||
'Flushing outgoing message queue',
|
||||
);
|
||||
while (this.outgoingQueue.length > 0) {
|
||||
const item = this.outgoingQueue.shift()!;
|
||||
// Send directly — queued items are already prefixed by sendMessage
|
||||
await this.sock.sendMessage(item.jid, { text: item.text });
|
||||
logger.info({ jid: item.jid, length: item.text.length }, 'Queued message sent');
|
||||
logger.info(
|
||||
{ jid: item.jid, length: item.text.length },
|
||||
'Queued message sent',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.flushing = false;
|
||||
|
||||
@@ -6,15 +6,13 @@ import { readEnvFile } from './env.js';
|
||||
// Read config values from .env (falls back to process.env).
|
||||
// Secrets are NOT read here — they stay on disk and are loaded only
|
||||
// where needed (container-runner.ts) to avoid leaking to child processes.
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
]);
|
||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
|
||||
|
||||
export const ASSISTANT_NAME =
|
||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
(process.env.ASSISTANT_HAS_OWN_NUMBER ||
|
||||
envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
||||
export const POLL_INTERVAL = 2000;
|
||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||
|
||||
@@ -45,10 +43,7 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
||||
10,
|
||||
); // 10MB default
|
||||
export const IPC_POLL_INTERVAL = 1000;
|
||||
export const IDLE_TIMEOUT = parseInt(
|
||||
process.env.IDLE_TIMEOUT || '1800000',
|
||||
10,
|
||||
); // 30min default — how long to keep container alive after last result
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
||||
1,
|
||||
parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
|
||||
|
||||
@@ -71,14 +71,17 @@ let fakeProc: ReturnType<typeof createFakeProcess>;
|
||||
|
||||
// Mock child_process.spawn
|
||||
vi.mock('child_process', async () => {
|
||||
const actual = await vi.importActual<typeof import('child_process')>('child_process');
|
||||
const actual =
|
||||
await vi.importActual<typeof import('child_process')>('child_process');
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(() => fakeProc),
|
||||
exec: vi.fn((_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => {
|
||||
exec: vi.fn(
|
||||
(_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => {
|
||||
if (cb) cb(null);
|
||||
return new EventEmitter();
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -99,7 +102,10 @@ const testInput = {
|
||||
isMain: false,
|
||||
};
|
||||
|
||||
function emitOutputMarker(proc: ReturnType<typeof createFakeProcess>, output: ContainerOutput) {
|
||||
function emitOutputMarker(
|
||||
proc: ReturnType<typeof createFakeProcess>,
|
||||
output: ContainerOutput,
|
||||
) {
|
||||
const json = JSON.stringify(output);
|
||||
proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
import { readEnvFile } from './env.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import {
|
||||
CONTAINER_RUNTIME_BIN,
|
||||
readonlyMountArgs,
|
||||
stopContainer,
|
||||
} from './container-runtime.js';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
@@ -107,7 +111,10 @@ function buildVolumeMounts(
|
||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
fs.writeFileSync(
|
||||
settingsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
// Enable agent swarms (subagent orchestration)
|
||||
// https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
|
||||
@@ -119,7 +126,11 @@ function buildVolumeMounts(
|
||||
// https://code.claude.com/docs/en/memory#manage-auto-memory
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
},
|
||||
}, null, 2) + '\n');
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
// Sync skills from container/skills/ into each group's .claude/skills/
|
||||
@@ -154,8 +165,18 @@ function buildVolumeMounts(
|
||||
// Copy agent-runner source into a per-group writable location so agents
|
||||
// can customize it (add tools, change behavior) without affecting other
|
||||
// groups. Recompiled on container startup via entrypoint.sh.
|
||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||
const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src');
|
||||
const agentRunnerSrc = path.join(
|
||||
projectRoot,
|
||||
'container',
|
||||
'agent-runner',
|
||||
'src',
|
||||
);
|
||||
const groupAgentRunnerDir = path.join(
|
||||
DATA_DIR,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'agent-runner-src',
|
||||
);
|
||||
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||
}
|
||||
@@ -186,7 +207,10 @@ function readSecrets(): Record<string, string> {
|
||||
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
|
||||
}
|
||||
|
||||
function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] {
|
||||
function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
): string[] {
|
||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||
|
||||
// Pass host timezone so container's local time matches the user's
|
||||
@@ -364,10 +388,16 @@ export async function runContainerAgent(
|
||||
|
||||
const killOnTimeout = () => {
|
||||
timedOut = true;
|
||||
logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully');
|
||||
logger.error(
|
||||
{ group: group.name, containerName },
|
||||
'Container timeout, stopping gracefully',
|
||||
);
|
||||
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
|
||||
if (err) {
|
||||
logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing');
|
||||
logger.warn(
|
||||
{ group: group.name, containerName, err },
|
||||
'Graceful stop failed, force killing',
|
||||
);
|
||||
container.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
@@ -388,7 +418,9 @@ export async function runContainerAgent(
|
||||
if (timedOut) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timeoutLog = path.join(logsDir, `container-${ts}.log`);
|
||||
fs.writeFileSync(timeoutLog, [
|
||||
fs.writeFileSync(
|
||||
timeoutLog,
|
||||
[
|
||||
`=== Container Run Log (TIMEOUT) ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
@@ -396,7 +428,8 @@ export async function runContainerAgent(
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
`Had Streaming Output: ${hadStreamingOutput}`,
|
||||
].join('\n'));
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Timeout after output = idle cleanup, not failure.
|
||||
// The agent already sent its response; this is just the
|
||||
@@ -431,7 +464,8 @@ export async function runContainerAgent(
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logFile = path.join(logsDir, `container-${timestamp}.log`);
|
||||
const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||
const isVerbose =
|
||||
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||
|
||||
const logLines = [
|
||||
`=== Container Run Log ===`,
|
||||
@@ -574,7 +608,10 @@ export async function runContainerAgent(
|
||||
|
||||
container.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error({ group: group.name, containerName, error: err }, 'Container spawn error');
|
||||
logger.error(
|
||||
{ group: group.name, containerName, error: err },
|
||||
'Container spawn error',
|
||||
);
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
|
||||
@@ -55,11 +55,13 @@ describe('ensureContainerRuntimeRunning', () => {
|
||||
ensureContainerRuntimeRunning();
|
||||
|
||||
expect(mockExecSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExecSync).toHaveBeenCalledWith(
|
||||
`${CONTAINER_RUNTIME_BIN} info`,
|
||||
{ stdio: 'pipe', timeout: 10000 },
|
||||
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
});
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Container runtime already running',
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
|
||||
});
|
||||
|
||||
it('throws when docker info fails', () => {
|
||||
@@ -79,7 +81,9 @@ describe('ensureContainerRuntimeRunning', () => {
|
||||
describe('cleanupOrphans', () => {
|
||||
it('stops orphaned nanoclaw containers', () => {
|
||||
// docker ps returns container names, one per line
|
||||
mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n');
|
||||
mockExecSync.mockReturnValueOnce(
|
||||
'nanoclaw-group1-111\nnanoclaw-group2-222\n',
|
||||
);
|
||||
// stop calls succeed
|
||||
mockExecSync.mockReturnValue('');
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import { logger } from './logger.js';
|
||||
export const CONTAINER_RUNTIME_BIN = 'docker';
|
||||
|
||||
/** Returns CLI args for a readonly bind mount. */
|
||||
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
|
||||
export function readonlyMountArgs(
|
||||
hostPath: string,
|
||||
containerPath: string,
|
||||
): string[] {
|
||||
return ['-v', `${hostPath}:${containerPath}:ro`];
|
||||
}
|
||||
|
||||
@@ -22,7 +25,10 @@ export function stopContainer(name: string): string {
|
||||
/** Ensure the container runtime is running, starting it if needed. */
|
||||
export function ensureContainerRuntimeRunning(): void {
|
||||
try {
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} info`, { stdio: 'pipe', timeout: 10000 });
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
});
|
||||
logger.debug('Container runtime already running');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to reach container runtime');
|
||||
@@ -65,10 +71,15 @@ export function cleanupOrphans(): void {
|
||||
for (const name of orphans) {
|
||||
try {
|
||||
execSync(stopContainer(name), { stdio: 'pipe' });
|
||||
} catch { /* already stopped */ }
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
|
||||
logger.info(
|
||||
{ count: orphans.length, names: orphans },
|
||||
'Stopped orphaned containers',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to clean up orphaned containers');
|
||||
|
||||
113
src/db.test.ts
113
src/db.test.ts
@@ -53,7 +53,11 @@ describe('storeMessage', () => {
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
|
||||
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
|
||||
const messages = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].id).toBe('msg-1');
|
||||
expect(messages[0].sender).toBe('123@s.whatsapp.net');
|
||||
@@ -73,7 +77,11 @@ describe('storeMessage', () => {
|
||||
timestamp: '2024-01-01T00:00:04.000Z',
|
||||
});
|
||||
|
||||
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
|
||||
const messages = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -91,7 +99,11 @@ describe('storeMessage', () => {
|
||||
});
|
||||
|
||||
// Message is stored (we can retrieve it — is_from_me doesn't affect retrieval)
|
||||
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
|
||||
const messages = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -116,7 +128,11 @@ describe('storeMessage', () => {
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
|
||||
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
|
||||
const messages = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].content).toBe('updated');
|
||||
});
|
||||
@@ -129,33 +145,57 @@ describe('getMessagesSince', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'm1', chat_jid: 'group@g.us', sender: 'Alice@s.whatsapp.net',
|
||||
sender_name: 'Alice', content: 'first', timestamp: '2024-01-01T00:00:01.000Z',
|
||||
id: 'm1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Alice@s.whatsapp.net',
|
||||
sender_name: 'Alice',
|
||||
content: 'first',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
store({
|
||||
id: 'm2', chat_jid: 'group@g.us', sender: 'Bob@s.whatsapp.net',
|
||||
sender_name: 'Bob', content: 'second', timestamp: '2024-01-01T00:00:02.000Z',
|
||||
id: 'm2',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Bob@s.whatsapp.net',
|
||||
sender_name: 'Bob',
|
||||
content: 'second',
|
||||
timestamp: '2024-01-01T00:00:02.000Z',
|
||||
});
|
||||
storeMessage({
|
||||
id: 'm3', chat_jid: 'group@g.us', sender: 'Bot@s.whatsapp.net',
|
||||
sender_name: 'Bot', content: 'bot reply', timestamp: '2024-01-01T00:00:03.000Z',
|
||||
id: 'm3',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Bot@s.whatsapp.net',
|
||||
sender_name: 'Bot',
|
||||
content: 'bot reply',
|
||||
timestamp: '2024-01-01T00:00:03.000Z',
|
||||
is_bot_message: true,
|
||||
});
|
||||
store({
|
||||
id: 'm4', chat_jid: 'group@g.us', sender: 'Carol@s.whatsapp.net',
|
||||
sender_name: 'Carol', content: 'third', timestamp: '2024-01-01T00:00:04.000Z',
|
||||
id: 'm4',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Carol@s.whatsapp.net',
|
||||
sender_name: 'Carol',
|
||||
content: 'third',
|
||||
timestamp: '2024-01-01T00:00:04.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns messages after the given timestamp', () => {
|
||||
const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:02.000Z', 'Andy');
|
||||
const msgs = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:02.000Z',
|
||||
'Andy',
|
||||
);
|
||||
// Should exclude m1, m2 (before/at timestamp), m3 (bot message)
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0].content).toBe('third');
|
||||
});
|
||||
|
||||
it('excludes bot messages via is_bot_message flag', () => {
|
||||
const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
|
||||
const msgs = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
'Andy',
|
||||
);
|
||||
const botMsgs = msgs.filter((m) => m.content === 'bot reply');
|
||||
expect(botMsgs).toHaveLength(0);
|
||||
});
|
||||
@@ -169,11 +209,18 @@ describe('getMessagesSince', () => {
|
||||
it('filters pre-migration bot messages via content prefix backstop', () => {
|
||||
// Simulate a message written before migration: has prefix but is_bot_message = 0
|
||||
store({
|
||||
id: 'm5', chat_jid: 'group@g.us', sender: 'Bot@s.whatsapp.net',
|
||||
sender_name: 'Bot', content: 'Andy: old bot reply',
|
||||
id: 'm5',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'Bot@s.whatsapp.net',
|
||||
sender_name: 'Bot',
|
||||
content: 'Andy: old bot reply',
|
||||
timestamp: '2024-01-01T00:00:05.000Z',
|
||||
});
|
||||
const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:04.000Z', 'Andy');
|
||||
const msgs = getMessagesSince(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:04.000Z',
|
||||
'Andy',
|
||||
);
|
||||
expect(msgs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -186,21 +233,37 @@ describe('getNewMessages', () => {
|
||||
storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
store({
|
||||
id: 'a1', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User', content: 'g1 msg1', timestamp: '2024-01-01T00:00:01.000Z',
|
||||
id: 'a1',
|
||||
chat_jid: 'group1@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'g1 msg1',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
});
|
||||
store({
|
||||
id: 'a2', chat_jid: 'group2@g.us', sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User', content: 'g2 msg1', timestamp: '2024-01-01T00:00:02.000Z',
|
||||
id: 'a2',
|
||||
chat_jid: 'group2@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'g2 msg1',
|
||||
timestamp: '2024-01-01T00:00:02.000Z',
|
||||
});
|
||||
storeMessage({
|
||||
id: 'a3', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User', content: 'bot reply', timestamp: '2024-01-01T00:00:03.000Z',
|
||||
id: 'a3',
|
||||
chat_jid: 'group1@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'bot reply',
|
||||
timestamp: '2024-01-01T00:00:03.000Z',
|
||||
is_bot_message: true,
|
||||
});
|
||||
store({
|
||||
id: 'a4', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User', content: 'g1 msg2', timestamp: '2024-01-01T00:00:04.000Z',
|
||||
id: 'a4',
|
||||
chat_jid: 'group1@g.us',
|
||||
sender: 'user@s.whatsapp.net',
|
||||
sender_name: 'User',
|
||||
content: 'g1 msg2',
|
||||
timestamp: '2024-01-01T00:00:04.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
52
src/db.ts
52
src/db.ts
@@ -5,7 +5,12 @@ import path from 'path';
|
||||
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js';
|
||||
import {
|
||||
NewMessage,
|
||||
RegisteredGroup,
|
||||
ScheduledTask,
|
||||
TaskRunLog,
|
||||
} from './types.js';
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
@@ -94,26 +99,30 @@ function createSchema(database: Database.Database): void {
|
||||
`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`,
|
||||
);
|
||||
// Backfill: mark existing bot messages that used the content prefix pattern
|
||||
database.prepare(
|
||||
`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`,
|
||||
).run(`${ASSISTANT_NAME}:%`);
|
||||
database
|
||||
.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`)
|
||||
.run(`${ASSISTANT_NAME}:%`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
// Add channel and is_group columns if they don't exist (migration for existing DBs)
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE chats ADD COLUMN channel TEXT`,
|
||||
);
|
||||
database.exec(
|
||||
`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`,
|
||||
);
|
||||
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
||||
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
|
||||
// Backfill from JID patterns
|
||||
database.exec(`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`);
|
||||
database.exec(`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`);
|
||||
database.exec(`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`);
|
||||
database.exec(`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`,
|
||||
);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`,
|
||||
);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
|
||||
);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
|
||||
);
|
||||
} catch {
|
||||
/* columns already exist */
|
||||
}
|
||||
@@ -540,14 +549,12 @@ export function getRegisteredGroup(
|
||||
containerConfig: row.container_config
|
||||
? JSON.parse(row.container_config)
|
||||
: undefined,
|
||||
requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
requiresTrigger:
|
||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function setRegisteredGroup(
|
||||
jid: string,
|
||||
group: RegisteredGroup,
|
||||
): void {
|
||||
export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
if (!isValidGroupFolder(group.folder)) {
|
||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||
}
|
||||
@@ -566,9 +573,7 @@ export function setRegisteredGroup(
|
||||
}
|
||||
|
||||
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
const rows = db
|
||||
.prepare('SELECT * FROM registered_groups')
|
||||
.all() as Array<{
|
||||
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
@@ -594,7 +599,8 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
containerConfig: row.container_config
|
||||
? JSON.parse(row.container_config)
|
||||
: undefined,
|
||||
requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
requiresTrigger:
|
||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -69,7 +69,12 @@ describe('formatMessages', () => {
|
||||
|
||||
it('formats multiple messages', () => {
|
||||
const msgs = [
|
||||
makeMsg({ id: '1', sender_name: 'Alice', content: 'hi', timestamp: 't1' }),
|
||||
makeMsg({
|
||||
id: '1',
|
||||
sender_name: 'Alice',
|
||||
content: 'hi',
|
||||
timestamp: 't1',
|
||||
}),
|
||||
makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }),
|
||||
];
|
||||
const result = formatMessages(msgs);
|
||||
@@ -154,9 +159,7 @@ describe('stripInternalTags', () => {
|
||||
|
||||
it('strips multiple internal tag blocks', () => {
|
||||
expect(
|
||||
stripInternalTags(
|
||||
'<internal>a</internal>hello<internal>b</internal>',
|
||||
),
|
||||
stripInternalTags('<internal>a</internal>hello<internal>b</internal>'),
|
||||
).toBe('hello');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import {
|
||||
isValidGroupFolder,
|
||||
resolveGroupFolderPath,
|
||||
resolveGroupIpcPath,
|
||||
} from './group-folder.js';
|
||||
|
||||
describe('group folder validation', () => {
|
||||
it('accepts normal group folder names', () => {
|
||||
@@ -20,9 +24,9 @@ describe('group folder validation', () => {
|
||||
|
||||
it('resolves safe paths under groups directory', () => {
|
||||
const resolved = resolveGroupFolderPath('family-chat');
|
||||
expect(
|
||||
resolved.endsWith(`${path.sep}groups${path.sep}family-chat`),
|
||||
).toBe(true);
|
||||
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves safe paths under data ipc directory', () => {
|
||||
|
||||
@@ -263,7 +263,12 @@ describe('GroupQueue', () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Register a process so closeStdin has a groupFolder
|
||||
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'test-group',
|
||||
);
|
||||
|
||||
// Enqueue a task while container is active but NOT idle
|
||||
const taskFn = vi.fn(async () => {});
|
||||
@@ -298,7 +303,12 @@ describe('GroupQueue', () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Register process and mark idle
|
||||
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'test-group',
|
||||
);
|
||||
queue.notifyIdle('group1@g.us');
|
||||
|
||||
// Clear previous writes, then enqueue a task
|
||||
@@ -332,7 +342,12 @@ describe('GroupQueue', () => {
|
||||
queue.setProcessMessagesFn(processMessages);
|
||||
queue.enqueueMessageCheck('group1@g.us');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'test-group',
|
||||
);
|
||||
|
||||
// Container becomes idle
|
||||
queue.notifyIdle('group1@g.us');
|
||||
@@ -368,7 +383,12 @@ describe('GroupQueue', () => {
|
||||
// Start a task (sets isTaskContainer = true)
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'test-group',
|
||||
);
|
||||
|
||||
// sendMessage should return false — user messages must not go to task containers
|
||||
const result = queue.sendMessage('group1@g.us', 'hello');
|
||||
@@ -396,7 +416,12 @@ describe('GroupQueue', () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Register process and enqueue a task (no idle yet — no preemption)
|
||||
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'test-group',
|
||||
);
|
||||
|
||||
const writeFileSync = vi.mocked(fs.default.writeFileSync);
|
||||
writeFileSync.mockClear();
|
||||
|
||||
@@ -123,7 +123,12 @@ export class GroupQueue {
|
||||
);
|
||||
}
|
||||
|
||||
registerProcess(groupJid: string, proc: ChildProcess, containerName: string, groupFolder?: string): void {
|
||||
registerProcess(
|
||||
groupJid: string,
|
||||
proc: ChildProcess,
|
||||
containerName: string,
|
||||
groupFolder?: string,
|
||||
): void {
|
||||
const state = this.getGroup(groupJid);
|
||||
state.process = proc;
|
||||
state.containerName = containerName;
|
||||
@@ -148,7 +153,8 @@ export class GroupQueue {
|
||||
*/
|
||||
sendMessage(groupJid: string, text: string): boolean {
|
||||
const state = this.getGroup(groupJid);
|
||||
if (!state.active || !state.groupFolder || state.isTaskContainer) return false;
|
||||
if (!state.active || !state.groupFolder || state.isTaskContainer)
|
||||
return false;
|
||||
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
||||
|
||||
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
|
||||
@@ -278,7 +284,10 @@ export class GroupQueue {
|
||||
if (state.pendingTasks.length > 0) {
|
||||
const task = state.pendingTasks.shift()!;
|
||||
this.runTask(groupJid, task).catch((err) =>
|
||||
logger.error({ groupJid, taskId: task.id, err }, 'Unhandled error in runTask (drain)'),
|
||||
logger.error(
|
||||
{ groupJid, taskId: task.id, err },
|
||||
'Unhandled error in runTask (drain)',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -286,7 +295,10 @@ export class GroupQueue {
|
||||
// Then pending messages
|
||||
if (state.pendingMessages) {
|
||||
this.runForGroup(groupJid, 'drain').catch((err) =>
|
||||
logger.error({ groupJid, err }, 'Unhandled error in runForGroup (drain)'),
|
||||
logger.error(
|
||||
{ groupJid, err },
|
||||
'Unhandled error in runForGroup (drain)',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -307,11 +319,17 @@ export class GroupQueue {
|
||||
if (state.pendingTasks.length > 0) {
|
||||
const task = state.pendingTasks.shift()!;
|
||||
this.runTask(nextJid, task).catch((err) =>
|
||||
logger.error({ groupJid: nextJid, taskId: task.id, err }, 'Unhandled error in runTask (waiting)'),
|
||||
logger.error(
|
||||
{ groupJid: nextJid, taskId: task.id, err },
|
||||
'Unhandled error in runTask (waiting)',
|
||||
),
|
||||
);
|
||||
} else if (state.pendingMessages) {
|
||||
this.runForGroup(nextJid, 'drain').catch((err) =>
|
||||
logger.error({ groupJid: nextJid, err }, 'Unhandled error in runForGroup (waiting)'),
|
||||
logger.error(
|
||||
{ groupJid: nextJid, err },
|
||||
'Unhandled error in runForGroup (waiting)',
|
||||
),
|
||||
);
|
||||
}
|
||||
// If neither pending, skip this group
|
||||
|
||||
80
src/index.ts
80
src/index.ts
@@ -15,7 +15,10 @@ import {
|
||||
writeGroupsSnapshot,
|
||||
writeTasksSnapshot,
|
||||
} from './container-runner.js';
|
||||
import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
|
||||
import {
|
||||
cleanupOrphans,
|
||||
ensureContainerRuntimeRunning,
|
||||
} from './container-runtime.js';
|
||||
import {
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
@@ -71,10 +74,7 @@ function loadState(): void {
|
||||
|
||||
function saveState(): void {
|
||||
setRouterState('last_timestamp', lastTimestamp);
|
||||
setRouterState(
|
||||
'last_agent_timestamp',
|
||||
JSON.stringify(lastAgentTimestamp),
|
||||
);
|
||||
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
|
||||
}
|
||||
|
||||
function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
@@ -120,7 +120,9 @@ export function getAvailableGroups(): import('./container-runner.js').AvailableG
|
||||
}
|
||||
|
||||
/** @internal - exported for testing */
|
||||
export function _setRegisteredGroups(groups: Record<string, RegisteredGroup>): void {
|
||||
export function _setRegisteredGroups(
|
||||
groups: Record<string, RegisteredGroup>,
|
||||
): void {
|
||||
registeredGroups = groups;
|
||||
}
|
||||
|
||||
@@ -134,14 +136,18 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
|
||||
const channel = findChannel(channels, chatJid);
|
||||
if (!channel) {
|
||||
console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
|
||||
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
|
||||
return true;
|
||||
}
|
||||
|
||||
const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
|
||||
|
||||
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
||||
const missedMessages = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
|
||||
const missedMessages = getMessagesSince(
|
||||
chatJid,
|
||||
sinceTimestamp,
|
||||
ASSISTANT_NAME,
|
||||
);
|
||||
|
||||
if (missedMessages.length === 0) return true;
|
||||
|
||||
@@ -173,7 +179,10 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
const resetIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
|
||||
logger.debug(
|
||||
{ group: group.name },
|
||||
'Idle timeout, closing container stdin',
|
||||
);
|
||||
queue.closeStdin(chatJid);
|
||||
}, IDLE_TIMEOUT);
|
||||
};
|
||||
@@ -185,7 +194,10 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
const output = await runAgent(group, prompt, chatJid, async (result) => {
|
||||
// Streaming output callback — called for each agent result
|
||||
if (result.result) {
|
||||
const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
|
||||
const raw =
|
||||
typeof result.result === 'string'
|
||||
? result.result
|
||||
: JSON.stringify(result.result);
|
||||
// Strip <internal>...</internal> blocks — agent uses these for internal reasoning
|
||||
const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
|
||||
@@ -213,13 +225,19 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
// If we already sent output to the user, don't roll back the cursor —
|
||||
// the user got their response and re-processing would send duplicates.
|
||||
if (outputSentToUser) {
|
||||
logger.warn({ group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates');
|
||||
logger.warn(
|
||||
{ group: group.name },
|
||||
'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Roll back cursor so retries can re-process these messages
|
||||
lastAgentTimestamp[chatJid] = previousCursor;
|
||||
saveState();
|
||||
logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
|
||||
logger.warn(
|
||||
{ group: group.name },
|
||||
'Agent error, rolled back message cursor for retry',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -282,7 +300,8 @@ async function runAgent(
|
||||
isMain,
|
||||
assistantName: ASSISTANT_NAME,
|
||||
},
|
||||
(proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
|
||||
(proc, containerName) =>
|
||||
queue.registerProcess(chatJid, proc, containerName, group.folder),
|
||||
wrappedOnOutput,
|
||||
);
|
||||
|
||||
@@ -318,7 +337,11 @@ async function startMessageLoop(): Promise<void> {
|
||||
while (true) {
|
||||
try {
|
||||
const jids = Object.keys(registeredGroups);
|
||||
const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
|
||||
const { messages, newTimestamp } = getNewMessages(
|
||||
jids,
|
||||
lastTimestamp,
|
||||
ASSISTANT_NAME,
|
||||
);
|
||||
|
||||
if (messages.length > 0) {
|
||||
logger.info({ count: messages.length }, 'New messages');
|
||||
@@ -344,7 +367,7 @@ async function startMessageLoop(): Promise<void> {
|
||||
|
||||
const channel = findChannel(channels, chatJid);
|
||||
if (!channel) {
|
||||
console.log(`Warning: no channel owns JID ${chatJid}, skipping messages`);
|
||||
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -381,7 +404,9 @@ async function startMessageLoop(): Promise<void> {
|
||||
messagesToSend[messagesToSend.length - 1].timestamp;
|
||||
saveState();
|
||||
// Show typing indicator while the container processes the piped message
|
||||
channel.setTyping?.(chatJid, true)?.catch((err) =>
|
||||
channel
|
||||
.setTyping?.(chatJid, true)
|
||||
?.catch((err) =>
|
||||
logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
|
||||
);
|
||||
} else {
|
||||
@@ -439,8 +464,13 @@ async function main(): Promise<void> {
|
||||
// Channel callbacks (shared by all channels)
|
||||
const channelOpts = {
|
||||
onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
|
||||
onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
|
||||
storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
||||
onChatMetadata: (
|
||||
chatJid: string,
|
||||
timestamp: string,
|
||||
name?: string,
|
||||
channel?: string,
|
||||
isGroup?: boolean,
|
||||
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
||||
registeredGroups: () => registeredGroups,
|
||||
};
|
||||
|
||||
@@ -454,11 +484,12 @@ async function main(): Promise<void> {
|
||||
registeredGroups: () => registeredGroups,
|
||||
getSessions: () => sessions,
|
||||
queue,
|
||||
onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder),
|
||||
onProcess: (groupJid, proc, containerName, groupFolder) =>
|
||||
queue.registerProcess(groupJid, proc, containerName, groupFolder),
|
||||
sendMessage: async (jid, rawText) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) {
|
||||
console.log(`Warning: no channel owns JID ${jid}, cannot send message`);
|
||||
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
||||
return;
|
||||
}
|
||||
const text = formatOutbound(rawText);
|
||||
@@ -473,9 +504,11 @@ async function main(): Promise<void> {
|
||||
},
|
||||
registeredGroups: () => registeredGroups,
|
||||
registerGroup,
|
||||
syncGroupMetadata: (force) => whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
|
||||
syncGroupMetadata: (force) =>
|
||||
whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
|
||||
getAvailableGroups,
|
||||
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
|
||||
writeGroupsSnapshot: (gf, im, ag, rj) =>
|
||||
writeGroupsSnapshot(gf, im, ag, rj),
|
||||
});
|
||||
queue.setProcessMessagesFn(processGroupMessages);
|
||||
recoverPendingMessages();
|
||||
@@ -488,7 +521,8 @@ async function main(): Promise<void> {
|
||||
// Guard: only run when executed directly, not when imported by tests
|
||||
const isDirectRun =
|
||||
process.argv[1] &&
|
||||
new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
|
||||
new URL(import.meta.url).pathname ===
|
||||
new URL(`file://${process.argv[1]}`).pathname;
|
||||
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -174,17 +174,32 @@ describe('pause_task authorization', () => {
|
||||
});
|
||||
|
||||
it('main group can pause any task', async () => {
|
||||
await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'main', true, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'pause_task', taskId: 'task-other' },
|
||||
'main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-other')!.status).toBe('paused');
|
||||
});
|
||||
|
||||
it('non-main group can pause its own task', async () => {
|
||||
await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'other-group', false, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'pause_task', taskId: 'task-other' },
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-other')!.status).toBe('paused');
|
||||
});
|
||||
|
||||
it('non-main group cannot pause another groups task', async () => {
|
||||
await processTaskIpc({ type: 'pause_task', taskId: 'task-main' }, 'other-group', false, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'pause_task', taskId: 'task-main' },
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-main')!.status).toBe('active');
|
||||
});
|
||||
});
|
||||
@@ -208,17 +223,32 @@ describe('resume_task authorization', () => {
|
||||
});
|
||||
|
||||
it('main group can resume any task', async () => {
|
||||
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'main', true, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'resume_task', taskId: 'task-paused' },
|
||||
'main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-paused')!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('non-main group can resume its own task', async () => {
|
||||
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'other-group', false, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'resume_task', taskId: 'task-paused' },
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-paused')!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('non-main group cannot resume another groups task', async () => {
|
||||
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'third-group', false, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'resume_task', taskId: 'task-paused' },
|
||||
'third-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-paused')!.status).toBe('paused');
|
||||
});
|
||||
});
|
||||
@@ -240,7 +270,12 @@ describe('cancel_task authorization', () => {
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await processTaskIpc({ type: 'cancel_task', taskId: 'task-to-cancel' }, 'main', true, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'cancel_task', taskId: 'task-to-cancel' },
|
||||
'main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-to-cancel')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -258,7 +293,12 @@ describe('cancel_task authorization', () => {
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await processTaskIpc({ type: 'cancel_task', taskId: 'task-own' }, 'other-group', false, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'cancel_task', taskId: 'task-own' },
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-own')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -276,7 +316,12 @@ describe('cancel_task authorization', () => {
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await processTaskIpc({ type: 'cancel_task', taskId: 'task-foreign' }, 'other-group', false, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'cancel_task', taskId: 'task-foreign' },
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
expect(getTaskById('task-foreign')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -325,7 +370,12 @@ describe('register_group authorization', () => {
|
||||
describe('refresh_groups authorization', () => {
|
||||
it('non-main group cannot trigger refresh', async () => {
|
||||
// This should be silently blocked (no crash, no effect)
|
||||
await processTaskIpc({ type: 'refresh_groups' }, 'other-group', false, deps);
|
||||
await processTaskIpc(
|
||||
{ type: 'refresh_groups' },
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
// If we got here without error, the auth gate worked
|
||||
});
|
||||
});
|
||||
@@ -352,21 +402,31 @@ describe('IPC message authorization', () => {
|
||||
});
|
||||
|
||||
it('non-main group can send to its own chat', () => {
|
||||
expect(isMessageAuthorized('other-group', false, 'other@g.us', groups)).toBe(true);
|
||||
expect(
|
||||
isMessageAuthorized('other-group', false, 'other@g.us', groups),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('non-main group cannot send to another groups chat', () => {
|
||||
expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(false);
|
||||
expect(isMessageAuthorized('other-group', false, 'third@g.us', groups)).toBe(false);
|
||||
expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isMessageAuthorized('other-group', false, 'third@g.us', groups),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('non-main group cannot send to unregistered JID', () => {
|
||||
expect(isMessageAuthorized('other-group', false, 'unknown@g.us', groups)).toBe(false);
|
||||
expect(
|
||||
isMessageAuthorized('other-group', false, 'unknown@g.us', groups),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('main group can send to unregistered JID', () => {
|
||||
// Main is always authorized regardless of target
|
||||
expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe(true);
|
||||
expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,7 +452,9 @@ describe('schedule_task schedule types', () => {
|
||||
expect(tasks[0].schedule_type).toBe('cron');
|
||||
expect(tasks[0].next_run).toBeTruthy();
|
||||
// next_run should be a valid ISO date in the future
|
||||
expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan(Date.now() - 60000);
|
||||
expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan(
|
||||
Date.now() - 60000,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid cron expression', async () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ export function escapeXml(s: string): string {
|
||||
}
|
||||
|
||||
export function formatMessages(messages: NewMessage[]): string {
|
||||
const lines = messages.map((m) =>
|
||||
const lines = messages.map(
|
||||
(m) =>
|
||||
`<message sender="${escapeXml(m.sender_name)}" time="${m.timestamp}">${escapeXml(m.content)}</message>`,
|
||||
);
|
||||
return `<messages>\n${lines.join('\n')}\n</messages>`;
|
||||
|
||||
@@ -28,9 +28,27 @@ describe('JID ownership patterns', () => {
|
||||
|
||||
describe('getAvailableGroups', () => {
|
||||
it('returns only groups, excludes DMs', () => {
|
||||
storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
|
||||
storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
|
||||
storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
|
||||
storeChatMetadata(
|
||||
'group1@g.us',
|
||||
'2024-01-01T00:00:01.000Z',
|
||||
'Group 1',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
storeChatMetadata(
|
||||
'user@s.whatsapp.net',
|
||||
'2024-01-01T00:00:02.000Z',
|
||||
'User DM',
|
||||
'whatsapp',
|
||||
false,
|
||||
);
|
||||
storeChatMetadata(
|
||||
'group2@g.us',
|
||||
'2024-01-01T00:00:03.000Z',
|
||||
'Group 2',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups).toHaveLength(2);
|
||||
@@ -41,7 +59,13 @@ describe('getAvailableGroups', () => {
|
||||
|
||||
it('excludes __group_sync__ sentinel', () => {
|
||||
storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
|
||||
storeChatMetadata(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:01.000Z',
|
||||
'Group',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups).toHaveLength(1);
|
||||
@@ -49,8 +73,20 @@ describe('getAvailableGroups', () => {
|
||||
});
|
||||
|
||||
it('marks registered groups correctly', () => {
|
||||
storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
|
||||
storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
|
||||
storeChatMetadata(
|
||||
'reg@g.us',
|
||||
'2024-01-01T00:00:01.000Z',
|
||||
'Registered',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
storeChatMetadata(
|
||||
'unreg@g.us',
|
||||
'2024-01-01T00:00:02.000Z',
|
||||
'Unregistered',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
|
||||
_setRegisteredGroups({
|
||||
'reg@g.us': {
|
||||
@@ -70,9 +106,27 @@ describe('getAvailableGroups', () => {
|
||||
});
|
||||
|
||||
it('returns groups ordered by most recent activity', () => {
|
||||
storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
|
||||
storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
|
||||
storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
|
||||
storeChatMetadata(
|
||||
'old@g.us',
|
||||
'2024-01-01T00:00:01.000Z',
|
||||
'Old',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
storeChatMetadata(
|
||||
'new@g.us',
|
||||
'2024-01-01T00:00:05.000Z',
|
||||
'New',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
storeChatMetadata(
|
||||
'mid@g.us',
|
||||
'2024-01-01T00:00:03.000Z',
|
||||
'Mid',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups[0].jid).toBe('new@g.us');
|
||||
@@ -82,11 +136,27 @@ describe('getAvailableGroups', () => {
|
||||
|
||||
it('excludes non-group chats regardless of JID format', () => {
|
||||
// Unknown JID format stored without is_group should not appear
|
||||
storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
|
||||
storeChatMetadata(
|
||||
'unknown-format-123',
|
||||
'2024-01-01T00:00:01.000Z',
|
||||
'Unknown',
|
||||
);
|
||||
// Explicitly non-group with unusual JID
|
||||
storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
|
||||
storeChatMetadata(
|
||||
'custom:abc',
|
||||
'2024-01-01T00:00:02.000Z',
|
||||
'Custom DM',
|
||||
'custom',
|
||||
false,
|
||||
);
|
||||
// A real group for contrast
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
|
||||
storeChatMetadata(
|
||||
'group@g.us',
|
||||
'2024-01-01T00:00:03.000Z',
|
||||
'Group',
|
||||
'whatsapp',
|
||||
true,
|
||||
);
|
||||
|
||||
const groups = getAvailableGroups();
|
||||
expect(groups).toHaveLength(1);
|
||||
|
||||
@@ -4,12 +4,15 @@ import fs from 'fs';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
IDLE_TIMEOUT,
|
||||
MAIN_GROUP_FOLDER,
|
||||
SCHEDULER_POLL_INTERVAL,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { ContainerOutput, runContainerAgent, writeTasksSnapshot } from './container-runner.js';
|
||||
import {
|
||||
ContainerOutput,
|
||||
runContainerAgent,
|
||||
writeTasksSnapshot,
|
||||
} from './container-runner.js';
|
||||
import {
|
||||
getAllTasks,
|
||||
getDueTasks,
|
||||
@@ -27,7 +30,12 @@ export interface SchedulerDependencies {
|
||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
||||
getSessions: () => Record<string, string>;
|
||||
queue: GroupQueue;
|
||||
onProcess: (groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string) => void;
|
||||
onProcess: (
|
||||
groupJid: string,
|
||||
proc: ChildProcess,
|
||||
containerName: string,
|
||||
groupFolder: string,
|
||||
) => void;
|
||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -136,7 +144,8 @@ async function runTask(
|
||||
isScheduledTask: true,
|
||||
assistantName: ASSISTANT_NAME,
|
||||
},
|
||||
(proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
|
||||
(proc, containerName) =>
|
||||
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
|
||||
async (streamedOutput: ContainerOutput) => {
|
||||
if (streamedOutput.result) {
|
||||
result = streamedOutput.result;
|
||||
@@ -227,10 +236,8 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
deps.queue.enqueueTask(
|
||||
currentTask.chat_jid,
|
||||
currentTask.id,
|
||||
() => runTask(currentTask, deps),
|
||||
deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () =>
|
||||
runTask(currentTask, deps),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -33,7 +33,10 @@ const usePairingCode = process.argv.includes('--pairing-code');
|
||||
const phoneArg = process.argv.find((_, i, arr) => arr[i - 1] === '--phone');
|
||||
|
||||
function askQuestion(prompt: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
@@ -42,7 +45,10 @@ function askQuestion(prompt: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function connectSocket(phoneNumber?: string, isReconnect = false): Promise<void> {
|
||||
async function connectSocket(
|
||||
phoneNumber?: string,
|
||||
isReconnect = false,
|
||||
): Promise<void> {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||
|
||||
if (state.creds.registered && !isReconnect) {
|
||||
@@ -55,7 +61,10 @@ async function connectSocket(phoneNumber?: string, isReconnect = false): Promise
|
||||
}
|
||||
|
||||
const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
|
||||
logger.warn({ err }, 'Failed to fetch latest WA Web version, using default');
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Failed to fetch latest WA Web version, using default',
|
||||
);
|
||||
return { version: undefined };
|
||||
});
|
||||
const sock = makeWASocket({
|
||||
@@ -127,7 +136,9 @@ async function connectSocket(phoneNumber?: string, isReconnect = false): Promise
|
||||
if (connection === 'open') {
|
||||
fs.writeFileSync(STATUS_FILE, 'authenticated');
|
||||
// Clean up QR file now that we're connected
|
||||
try { fs.unlinkSync(QR_FILE); } catch {}
|
||||
try {
|
||||
fs.unlinkSync(QR_FILE);
|
||||
} catch {}
|
||||
console.log('\n✓ Successfully authenticated with WhatsApp!');
|
||||
console.log(' Credentials saved to store/auth/');
|
||||
console.log(' You can now start the NanoClaw service.\n');
|
||||
@@ -144,12 +155,18 @@ async function authenticate(): Promise<void> {
|
||||
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
||||
|
||||
// Clean up any stale QR/status files from previous runs
|
||||
try { fs.unlinkSync(QR_FILE); } catch {}
|
||||
try { fs.unlinkSync(STATUS_FILE); } catch {}
|
||||
try {
|
||||
fs.unlinkSync(QR_FILE);
|
||||
} catch {}
|
||||
try {
|
||||
fs.unlinkSync(STATUS_FILE);
|
||||
} catch {}
|
||||
|
||||
let phoneNumber = phoneArg;
|
||||
if (usePairingCode && !phoneNumber) {
|
||||
phoneNumber = await askQuestion('Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ');
|
||||
phoneNumber = await askQuestion(
|
||||
'Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Starting WhatsApp authentication...\n');
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['.claude/skills/**/tests/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user