From 11c201088b79c20b900f37d8d11c8b74d70a1772 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 25 Feb 2026 23:13:36 +0200 Subject: [PATCH] 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 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/{test.yml => ci.yml} | 15 +- .github/workflows/skill-drift.yml | 102 +++++++ .github/workflows/skill-pr.yml | 151 ++++++++++ .github/workflows/skill-tests.yml | 84 ------ .github/workflows/skills-only.yml | 52 ---- scripts/apply-skill.ts | 14 +- scripts/fix-skill-drift.ts | 266 +++++++++++++++++ scripts/generate-ci-matrix.ts | 118 -------- scripts/run-ci-tests.ts | 144 ---------- scripts/run-migrations.ts | 13 +- scripts/uninstall-skill.ts | 4 +- scripts/validate-all-skills.ts | 252 ++++++++++++++++ setup/container.ts | 39 ++- setup/environment.test.ts | 42 ++- setup/environment.ts | 24 +- setup/groups.ts | 36 ++- setup/index.ts | 13 +- setup/mounts.ts | 22 +- setup/platform.test.ts | 1 - setup/platform.ts | 4 +- setup/register.test.ts | 70 ++++- setup/register.ts | 47 ++- setup/service.test.ts | 62 +++- setup/service.ts | 39 ++- setup/verify.ts | 23 +- setup/whatsapp-auth.ts | 84 ++++-- skills-engine/__tests__/backup.test.ts | 16 +- skills-engine/__tests__/ci-matrix.test.ts | 270 ------------------ skills-engine/__tests__/customize.test.ts | 14 +- .../__tests__/fetch-upstream.test.ts | 65 ++--- skills-engine/__tests__/file-ops.test.ts | 88 +++--- skills-engine/__tests__/manifest.test.ts | 167 +++++++---- skills-engine/__tests__/path-remap.test.ts | 6 +- skills-engine/__tests__/rebase.test.ts | 5 +- skills-engine/__tests__/replay.test.ts | 24 +- .../__tests__/run-migrations.test.ts | 11 +- skills-engine/__tests__/state.test.ts | 4 +- skills-engine/__tests__/structured.test.ts | 85 ++++-- skills-engine/__tests__/test-helpers.ts | 49 ++-- skills-engine/__tests__/uninstall.test.ts | 6 +- .../__tests__/update-core-cli.test.ts | 18 +- skills-engine/__tests__/update.test.ts | 7 +- skills-engine/apply.ts | 39 ++- skills-engine/constants.ts | 7 +- skills-engine/customize.ts | 14 +- skills-engine/file-ops.ts | 21 +- skills-engine/index.ts | 5 +- skills-engine/init.ts | 13 +- skills-engine/lock.ts | 14 +- skills-engine/manifest.ts | 9 +- skills-engine/migrate.ts | 6 +- skills-engine/path-remap.ts | 9 +- skills-engine/rebase.ts | 10 +- skills-engine/state.ts | 6 +- skills-engine/structured.ts | 9 +- skills-engine/update.ts | 25 +- src/channels/whatsapp.test.ts | 73 +++-- src/channels/whatsapp.ts | 73 +++-- src/config.ts | 13 +- src/container-runner.test.ts | 18 +- src/container-runner.ts | 97 +++++-- src/container-runtime.test.ts | 14 +- src/container-runtime.ts | 19 +- src/db.test.ts | 113 ++++++-- src/db.ts | 52 ++-- src/formatting.test.ts | 11 +- src/group-folder.test.ts | 12 +- src/group-queue.test.ts | 35 ++- src/group-queue.ts | 30 +- src/index.ts | 84 ++++-- src/ipc-auth.test.ts | 94 ++++-- src/router.ts | 5 +- src/routing.test.ts | 94 +++++- src/task-scheduler.ts | 23 +- src/whatsapp-auth.ts | 31 +- vitest.skills.config.ts | 7 - 76 files changed, 2333 insertions(+), 1308 deletions(-) rename .github/workflows/{test.yml => ci.yml} (56%) create mode 100644 .github/workflows/skill-drift.yml create mode 100644 .github/workflows/skill-pr.yml delete mode 100644 .github/workflows/skill-tests.yml delete mode 100644 .github/workflows/skills-only.yml create mode 100644 scripts/fix-skill-drift.ts delete mode 100644 scripts/generate-ci-matrix.ts delete mode 100644 scripts/run-ci-tests.ts create mode 100644 scripts/validate-all-skills.ts delete mode 100644 skills-engine/__tests__/ci-matrix.test.ts delete mode 100644 vitest.skills.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/ci.yml similarity index 56% rename from .github/workflows/test.yml rename to .github/workflows/ci.yml index 4f25afc..e11c2f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/skill-drift.yml b/.github/workflows/skill-drift.yml new file mode 100644 index 0000000..9bc7ed8 --- /dev/null +++ b/.github/workflows/skill-drift.yml @@ -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' diff --git a/.github/workflows/skill-pr.yml b/.github/workflows/skill-pr.yml new file mode 100644 index 0000000..7ecd71a --- /dev/null +++ b/.github/workflows/skill-pr.yml @@ -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" diff --git a/.github/workflows/skill-tests.yml b/.github/workflows/skill-tests.yml deleted file mode 100644 index 9de72ad..0000000 --- a/.github/workflows/skill-tests.yml +++ /dev/null @@ -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<> "$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 diff --git a/.github/workflows/skills-only.yml b/.github/workflows/skills-only.yml deleted file mode 100644 index a50e5ba..0000000 --- a/.github/workflows/skills-only.yml +++ /dev/null @@ -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.` - }) diff --git a/scripts/apply-skill.ts b/scripts/apply-skill.ts index 514808d..db31bdc 100644 --- a/scripts/apply-skill.ts +++ b/scripts/apply-skill.ts @@ -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 '); + console.error('Usage: tsx scripts/apply-skill.ts [--init] '); process.exit(1); } diff --git a/scripts/fix-skill-drift.ts b/scripts/fix-skill-drift.ts new file mode 100644 index 0000000..ffa5c35 --- /dev/null +++ b/scripts/fix-skill-drift.ts @@ -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 + * - 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 { + const projectRoot = process.cwd(); + const skillNames = process.argv.slice(2); + + if (skillNames.length === 0) { + console.error( + 'Usage: npx tsx scripts/fix-skill-drift.ts [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); +}); diff --git a/scripts/generate-ci-matrix.ts b/scripts/generate-ci-matrix.ts deleted file mode 100644 index f126036..0000000 --- a/scripts/generate-ci-matrix.ts +++ /dev/null @@ -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)); -} diff --git a/scripts/run-ci-tests.ts b/scripts/run-ci-tests.ts deleted file mode 100644 index 0f9e740..0000000 --- a/scripts/run-ci-tests.ts +++ /dev/null @@ -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 { - 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 { - 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); -}); diff --git a/scripts/run-migrations.ts b/scripts/run-migrations.ts index e803255..355312a 100644 --- a/scripts/run-migrations.ts +++ b/scripts/run-migrations.ts @@ -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 diff --git a/scripts/uninstall-skill.ts b/scripts/uninstall-skill.ts index f073c94..a3d6682 100644 --- a/scripts/uninstall-skill.ts +++ b/scripts/uninstall-skill.ts @@ -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); } diff --git a/scripts/validate-all-skills.ts b/scripts/validate-all-skills.ts new file mode 100644 index 0000000..5208a90 --- /dev/null +++ b/scripts/validate-all-skills.ts @@ -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 { + 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); +}); diff --git a/setup/container.ts b/setup/container.ts index 7122898..cc44350 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -42,8 +42,13 @@ export async function run(args: string[]): Promise { // 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 { 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 { 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 { 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 diff --git a/setup/environment.test.ts b/setup/environment.test.ts index efae7ad..b33f272 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -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); }); }); diff --git a/setup/environment.ts b/setup/environment.ts index a7f436f..b9814ee 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -43,9 +43,7 @@ export async function run(_args: string[]): Promise { 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 { 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 { } } - 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, diff --git a/setup/groups.ts b/setup/groups.ts index 6d18998..d251d5d 100644 --- a/setup/groups.ts +++ b/setup/groups.ts @@ -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 { } const db = new Database(dbPath, { readonly: true }); - const rows = db.prepare( - `SELECT jid, name FROM chats + 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)}`, { - cwd: projectRoot, - encoding: 'utf-8', - timeout: 45000, - stdio: ['ignore', 'pipe', 'pipe'], - }); + 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( - "SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'", - ).get() as { count: number }; + const row = db + .prepare( + "SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'", + ) + .get() as { count: number }; groupsInDb = row.count; db.close(); } catch { diff --git a/setup/index.ts b/setup/index.ts index 2d96f81..287a790 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -5,7 +5,10 @@ import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; -const STEPS: Record Promise<{ run: (args: string[]) => Promise }>> = { +const STEPS: Record< + string, + () => Promise<{ run: (args: string[]) => Promise }> +> = { environment: () => import('./environment.js'), container: () => import('./container.js'), 'whatsapp-auth': () => import('./whatsapp-auth.js'), @@ -21,12 +24,16 @@ async function main(): Promise { 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) { diff --git a/setup/mounts.ts b/setup/mounts.ts index 061b329..eb2a5f6 100644 --- a/setup/mounts.ts +++ b/setup/mounts.ts @@ -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 { 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 { } 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 { } 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, diff --git a/setup/platform.test.ts b/setup/platform.test.ts index 266370a..1604d3e 100644 --- a/setup/platform.test.ts +++ b/setup/platform.test.ts @@ -19,7 +19,6 @@ describe('getPlatform', () => { const result = getPlatform(); expect(['macos', 'linux', 'unknown']).toContain(result); }); - }); // --- isWSL --- diff --git a/setup/platform.ts b/setup/platform.ts index 2f9843e..5544eac 100644 --- a/setup/platform.ts +++ b/setup/platform.ts @@ -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 diff --git a/setup/register.test.ts b/setup/register.test.ts index ac49433..7258445 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -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); diff --git a/setup/register.ts b/setup/register.ts index acbca91..55c3569 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -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 { `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 { 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'); } diff --git a/setup/service.test.ts b/setup/service.test.ts index 27162c1..eb15db8 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -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 ` @@ -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('com.nanoclaw'); }); 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('/opt/node/bin/node'); }); 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', + ); }); }); diff --git a/setup/service.ts b/setup/service.ts index 7026331..9e7932a 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -68,8 +68,17 @@ export async function run(_args: string[]): Promise { } } -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 = ` @@ -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'); diff --git a/setup/verify.ts b/setup/verify.ts index 0e563f3..a738b8c 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -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 { @@ -48,7 +53,9 @@ export async function run(_args: string[]): Promise { 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 { 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 { // 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'; } diff --git a/setup/whatsapp-auth.ts b/setup/whatsapp-auth.ts index f336913..feed41b 100644 --- a/setup/whatsapp-auth.ts +++ b/setup/whatsapp-auth.ts @@ -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 { 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,14 +158,30 @@ export async function run(args: string[]): Promise { // 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' - ? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone] - : ['src/whatsapp-auth.ts']; + const authArgs = + method === 'pairing-code' + ? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone] + : ['src/whatsapp-auth.ts']; const authProc = spawn('npx', ['tsx', ...authArgs], { cwd: projectRoot, @@ -165,7 +196,11 @@ export async function run(args: string[]): Promise { // 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( diff --git a/skills-engine/__tests__/backup.test.ts b/skills-engine/__tests__/backup.test.ts index 132a891..aeeb6ee 100644 --- a/skills-engine/__tests__/backup.test.ts +++ b/skills-engine/__tests__/backup.test.ts @@ -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); }); diff --git a/skills-engine/__tests__/ci-matrix.test.ts b/skills-engine/__tests__/ci-matrix.test.ts deleted file mode 100644 index 10781d1..0000000 --- a/skills-engine/__tests__/ci-matrix.test.ts +++ /dev/null @@ -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 & { 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): 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'); - }); - }); -}); diff --git a/skills-engine/__tests__/customize.test.ts b/skills-engine/__tests__/customize.test.ts index c188d26..1c055a2 100644 --- a/skills-engine/__tests__/customize.test.ts +++ b/skills-engine/__tests__/customize.test.ts @@ -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); diff --git a/skills-engine/__tests__/fetch-upstream.test.ts b/skills-engine/__tests__/fetch-upstream.test.ts index 5e27c22..ca2f6ab 100644 --- a/skills-engine/__tests__/fetch-upstream.test.ts +++ b/skills-engine/__tests__/fetch-upstream.test.ts @@ -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}`, { diff --git a/skills-engine/__tests__/file-ops.test.ts b/skills-engine/__tests__/file-ops.test.ts index ee9a0bb..bfb32e8 100644 --- a/skills-engine/__tests__/file-ops.test.ts +++ b/skills-engine/__tests__/file-ops.test.ts @@ -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); diff --git a/skills-engine/__tests__/manifest.test.ts b/skills-engine/__tests__/manifest.test.ts index 3af7274..b5f695a 100644 --- a/skills-engine/__tests__/manifest.test.ts +++ b/skills-engine/__tests__/manifest.test.ts @@ -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,18 +278,21 @@ 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({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - author: 'tester', - license: 'MIT', - min_skills_system_version: '0.1.0', - tested_with: ['telegram', 'discord'], - post_apply: ['echo done'], - })); + fs.writeFileSync( + path.join(dir, 'manifest.yaml'), + stringify({ + skill: 'test', + version: '1.0.0', + core_version: '1.0.0', + adds: [], + modifies: [], + author: 'tester', + license: 'MIT', + 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({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - min_skills_system_version: '0.1.0', - })); + 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({ - skill: 'test', - version: '1.0.0', - core_version: '1.0.0', - adds: [], - modifies: [], - min_skills_system_version: '99.0.0', - })); + 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); diff --git a/skills-engine/__tests__/path-remap.test.ts b/skills-engine/__tests__/path-remap.test.ts index 8ba3d39..e37b82c 100644 --- a/skills-engine/__tests__/path-remap.test.ts +++ b/skills-engine/__tests__/path-remap.test.ts @@ -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, diff --git a/skills-engine/__tests__/rebase.test.ts b/skills-engine/__tests__/rebase.test.ts index 2badb25..a7aaa3f 100644 --- a/skills-engine/__tests__/rebase.test.ts +++ b/skills-engine/__tests__/rebase.test.ts @@ -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 }); diff --git a/skills-engine/__tests__/replay.test.ts b/skills-engine/__tests__/replay.test.ts index e4a1d59..9d0aa34 100644 --- a/skills-engine/__tests__/replay.test.ts +++ b/skills-engine/__tests__/replay.test.ts @@ -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, }); diff --git a/skills-engine/__tests__/run-migrations.test.ts b/skills-engine/__tests__/run-migrations.test.ts index a6198f6..bc208ac 100644 --- a/skills-engine/__tests__/run-migrations.test.ts +++ b/skills-engine/__tests__/run-migrations.test.ts @@ -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 }; diff --git a/skills-engine/__tests__/state.test.ts b/skills-engine/__tests__/state.test.ts index 4bb1d60..e4cdbb1 100644 --- a/skills-engine/__tests__/state.test.ts +++ b/skills-engine/__tests__/state.test.ts @@ -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', () => { diff --git a/skills-engine/__tests__/structured.test.ts b/skills-engine/__tests__/structured.test.ts index da432f3..1d98f27 100644 --- a/skills-engine/__tests__/structured.test.ts +++ b/skills-engine/__tests__/structured.test.ts @@ -68,10 +68,17 @@ describe('structured', () => { describe('mergeNpmDependencies', () => { it('adds new dependencies', () => { const pkgPath = path.join(tmpDir, 'package.json'); - fs.writeFileSync(pkgPath, JSON.stringify({ - name: 'test', - dependencies: { existing: '^1.0.0' }, - }, null, 2)); + fs.writeFileSync( + pkgPath, + JSON.stringify( + { + name: 'test', + dependencies: { existing: '^1.0.0' }, + }, + 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({ - name: 'test', - dependencies: { dep: '^1.0.0' }, - }, null, 2)); + fs.writeFileSync( + pkgPath, + JSON.stringify( + { + name: 'test', + dependencies: { dep: '^1.0.0' }, + }, + 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({ - name: 'test', - dependencies: {}, - devDependencies: { zlib: '^1.0.0', acorn: '^2.0.0' }, - }, null, 2)); + fs.writeFileSync( + pkgPath, + JSON.stringify( + { + name: 'test', + dependencies: {}, + devDependencies: { zlib: '^1.0.0', acorn: '^2.0.0' }, + }, + 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({ - name: 'test', - dependencies: { dep: '^1.0.0' }, - }, null, 2)); + fs.writeFileSync( + pkgPath, + JSON.stringify( + { + name: 'test', + dependencies: { dep: '^1.0.0' }, + }, + 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, { - api: { image: 'node', ports: ['8080:3000'] }, - })).toThrow(); + expect(() => + mergeDockerComposeServices(composePath, { + api: { image: 'node', ports: ['8080:3000'] }, + }), + ).toThrow(); }); }); }); diff --git a/skills-engine/__tests__/test-helpers.ts b/skills-engine/__tests__/test-helpers.ts index 31edfee..bd3db0b 100644 --- a/skills-engine/__tests__/test-helpers.ts +++ b/skills-engine/__tests__/test-helpers.ts @@ -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,23 +28,26 @@ export function createMinimalState(tmpDir: string): void { }); } -export function createSkillPackage(tmpDir: string, opts: { - skill?: string; - version?: string; - core_version?: string; - adds?: string[]; - modifies?: string[]; - addFiles?: Record; - modifyFiles?: Record; - conflicts?: string[]; - depends?: string[]; - test?: string; - structured?: any; - file_ops?: any[]; - post_apply?: string[]; - min_skills_system_version?: string; - dirName?: string; -}): string { +export function createSkillPackage( + tmpDir: string, + opts: { + skill?: string; + version?: string; + core_version?: string; + adds?: string[]; + modifies?: string[]; + addFiles?: Record; + modifyFiles?: Record; + conflicts?: string[]; + depends?: string[]; + test?: string; + structured?: any; + file_ops?: any[]; + post_apply?: string[]; + min_skills_system_version?: string; + dirName?: 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'); diff --git a/skills-engine/__tests__/uninstall.test.ts b/skills-engine/__tests__/uninstall.test.ts index d6fd5c1..7bb24fd 100644 --- a/skills-engine/__tests__/uninstall.test.ts +++ b/skills-engine/__tests__/uninstall.test.ts @@ -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', }, }); diff --git a/skills-engine/__tests__/update-core-cli.test.ts b/skills-engine/__tests__/update-core-cli.test.ts index faa3de5..c95e65d 100644 --- a/skills-engine/__tests__/update-core-cli.test.ts +++ b/skills-engine/__tests__/update-core-cli.test.ts @@ -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); diff --git a/skills-engine/__tests__/update.test.ts b/skills-engine/__tests__/update.test.ts index 1e2646f..a4091ed 100644 --- a/skills-engine/__tests__/update.test.ts +++ b/skills-engine/__tests__/update.test.ts @@ -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(); diff --git a/skills-engine/apply.ts b/skills-engine/apply.ts index 774673b..3c114df 100644 --- a/skills-engine/apply.ts +++ b/skills-engine/apply.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { releaseLock(); } } - diff --git a/skills-engine/constants.ts b/skills-engine/constants.ts index eb58fc5..93bd5e1 100644 --- a/skills-engine/constants.ts +++ b/skills-engine/constants.ts @@ -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/', +]; diff --git a/skills-engine/customize.ts b/skills-engine/customize.ts index 97b43c6..e7ec330 100644 --- a/skills-engine/customize.ts +++ b/skills-engine/customize.ts @@ -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; } diff --git a/skills-engine/file-ops.ts b/skills-engine/file-ops.ts index ea24c5f..6d656c5 100644 --- a/skills-engine/file-ops.ts +++ b/skills-engine/file-ops.ts @@ -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; } diff --git a/skills-engine/index.ts b/skills-engine/index.ts index b1866a1..5c35ed2 100644 --- a/skills-engine/index.ts +++ b/skills-engine/index.ts @@ -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, diff --git a/skills-engine/init.ts b/skills-engine/init.ts index 718b627..9f43b5d 100644 --- a/skills-engine/init.ts +++ b/skills-engine/init.ts @@ -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 })) { diff --git a/skills-engine/lock.ts b/skills-engine/lock.ts index acb87e1..20814c4 100644 --- a/skills-engine/lock.ts +++ b/skills-engine/lock.ts @@ -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(); } diff --git a/skills-engine/manifest.ts b/skills-engine/manifest.ts index 587ca38..5522901 100644 --- a/skills-engine/manifest.ts +++ b/skills-engine/manifest.ts @@ -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, diff --git a/skills-engine/migrate.ts b/skills-engine/migrate.ts index fa22b2b..d604c23 100644 --- a/skills-engine/migrate.ts +++ b/skills-engine/migrate.ts @@ -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)] diff --git a/skills-engine/path-remap.ts b/skills-engine/path-remap.ts index cd498a4..2de54dc 100644 --- a/skills-engine/path-remap.ts +++ b/skills-engine/path-remap.ts @@ -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; diff --git a/skills-engine/rebase.ts b/skills-engine/rebase.ts index b7adf06..7b5d830 100644 --- a/skills-engine/rebase.ts +++ b/skills-engine/rebase.ts @@ -27,9 +27,7 @@ function walkDir(dir: string, root: string): string[] { return results; } -function collectTrackedFiles( - state: ReturnType, -): Set { +function collectTrackedFiles(state: ReturnType): Set { const tracked = new Set(); for (const skill of state.applied_skills) { @@ -119,11 +117,7 @@ export async function rebase(newBasePath?: string): Promise { } // 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) { diff --git a/skills-engine/state.ts b/skills-engine/state.ts index d87b3ed..6754116 100644 --- a/skills-engine/state.ts +++ b/skills-engine/state.ts @@ -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 { diff --git a/skills-engine/structured.ts b/skills-engine/structured.ts index 76ad412..2d64171 100644 --- a/skills-engine/structured.ts +++ b/skills-engine/structured.ts @@ -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(), + }); } diff --git a/skills-engine/update.ts b/skills-engine/update.ts index 7338270..5d2e7f7 100644 --- a/skills-engine/update.ts +++ b/skills-engine/update.ts @@ -229,9 +229,16 @@ export async function applyUpdate(newCorePath: string): Promise { } // --- 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; + 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 { let hasNpmDeps = false; for (const skill of state.applied_skills) { - const outcomes = skill.structured_outcomes as Record | undefined; + const outcomes = skill.structured_outcomes as + | Record + | undefined; if (!outcomes) continue; if (outcomes.npm_dependencies) { - Object.assign(allNpmDeps, outcomes.npm_dependencies as Record); + Object.assign( + allNpmDeps, + outcomes.npm_dependencies as Record, + ); hasNpmDeps = true; } if (outcomes.env_additions) { @@ -292,7 +304,9 @@ export async function applyUpdate(newCorePath: string): Promise { const skillReapplyResults: Record = {}; for (const skill of state.applied_skills) { - const outcomes = skill.structured_outcomes as Record | undefined; + const outcomes = skill.structured_outcomes as + | Record + | undefined; if (!outcomes?.test) continue; const testCmd = outcomes.test as string; @@ -339,4 +353,3 @@ export async function applyUpdate(newCorePath: string): Promise { releaseLock(); } } - diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts index 5baa9fc..d7d0875 100644 --- a/src/channels/whatsapp.test.ts +++ b/src/channels/whatsapp.test.ts @@ -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 { +function createTestOpts( + overrides?: Partial, +): 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(); }); }); diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 6c912f3..f603025 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -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; diff --git a/src/config.ts b/src/config.ts index da25429..8a4cb92 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index 1875c3f..67af8e2 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -71,14 +71,17 @@ let fakeProc: ReturnType; // Mock child_process.spawn vi.mock('child_process', async () => { - const actual = await vi.importActual('child_process'); + const actual = + await vi.importActual('child_process'); return { ...actual, spawn: vi.fn(() => fakeProc), - exec: vi.fn((_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => { - if (cb) cb(null); - return new EventEmitter(); - }), + 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, output: ContainerOutput) { +function emitOutputMarker( + proc: ReturnType, + output: ContainerOutput, +) { const json = JSON.stringify(output); proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`); } diff --git a/src/container-runner.ts b/src/container-runner.ts index 424d060..1af5b52 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -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,19 +111,26 @@ function buildVolumeMounts( fs.mkdirSync(groupSessionsDir, { recursive: true }); const settingsFile = path.join(groupSessionsDir, 'settings.json'); if (!fs.existsSync(settingsFile)) { - 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 - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - // Load CLAUDE.md from additional mounted directories - // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - // Enable Claude's memory feature (persists user preferences between sessions) - // https://code.claude.com/docs/en/memory#manage-auto-memory - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, null, 2) + '\n'); + 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 + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + // Load CLAUDE.md from additional mounted directories + // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + // Enable Claude's memory feature (persists user preferences between sessions) + // https://code.claude.com/docs/en/memory#manage-auto-memory + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, + 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 { 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,15 +418,18 @@ export async function runContainerAgent( if (timedOut) { const ts = new Date().toISOString().replace(/[:.]/g, '-'); const timeoutLog = path.join(logsDir, `container-${ts}.log`); - fs.writeFileSync(timeoutLog, [ - `=== Container Run Log (TIMEOUT) ===`, - `Timestamp: ${new Date().toISOString()}`, - `Group: ${group.name}`, - `Container: ${containerName}`, - `Duration: ${duration}ms`, - `Exit Code: ${code}`, - `Had Streaming Output: ${hadStreamingOutput}`, - ].join('\n')); + fs.writeFileSync( + timeoutLog, + [ + `=== Container Run Log (TIMEOUT) ===`, + `Timestamp: ${new Date().toISOString()}`, + `Group: ${group.name}`, + `Container: ${containerName}`, + `Duration: ${duration}ms`, + `Exit Code: ${code}`, + `Had Streaming Output: ${hadStreamingOutput}`, + ].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, diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index de45361..08ffd59 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -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(''); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 592c4cc..4d417ad 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -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'); diff --git a/src/db.test.ts b/src/db.test.ts index 1389c27..e7f772c 100644 --- a/src/db.test.ts +++ b/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', }); }); diff --git a/src/db.ts b/src/db.ts index 92e2d14..9d9a4d5 100644 --- a/src/db.ts +++ b/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 { - 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 { 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; diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 647905d..ea85b9d 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -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( - 'ahellob', - ), + stripInternalTags('ahellob'), ).toBe('hello'); }); diff --git a/src/group-folder.test.ts b/src/group-folder.test.ts index 93b0261..b88d268 100644 --- a/src/group-folder.test.ts +++ b/src/group-folder.test.ts @@ -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', () => { diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts index 75ef506..b1a4f9c 100644 --- a/src/group-queue.test.ts +++ b/src/group-queue.test.ts @@ -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(); diff --git a/src/group-queue.ts b/src/group-queue.ts index 279a598..06a56cc 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 6b70b3f..278a7a7 100644 --- a/src/index.ts +++ b/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): void { +export function _setRegisteredGroups( + groups: Record, +): void { registeredGroups = groups; } @@ -134,14 +136,18 @@ async function processGroupMessages(chatJid: string): Promise { 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 { 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 { 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 ... blocks — agent uses these for internal reasoning const text = raw.replace(/[\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 { // 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 { 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 { 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,9 +404,11 @@ async function startMessageLoop(): Promise { messagesToSend[messagesToSend.length - 1].timestamp; saveState(); // Show typing indicator while the container processes the piped message - channel.setTyping?.(chatJid, true)?.catch((err) => - logger.warn({ chatJid, err }, 'Failed to set typing indicator'), - ); + channel + .setTyping?.(chatJid, true) + ?.catch((err) => + logger.warn({ chatJid, err }, 'Failed to set typing indicator'), + ); } else { // No active container — enqueue for a new one queue.enqueueMessageCheck(chatJid); @@ -439,8 +464,13 @@ async function main(): Promise { // 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 { 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 { }, 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 { // 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) => { diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index ad5754c..e155d44 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -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 () => { diff --git a/src/router.ts b/src/router.ts index c329009..3c9fbc0 100644 --- a/src/router.ts +++ b/src/router.ts @@ -10,8 +10,9 @@ export function escapeXml(s: string): string { } export function formatMessages(messages: NewMessage[]): string { - const lines = messages.map((m) => - `${escapeXml(m.content)}`, + const lines = messages.map( + (m) => + `${escapeXml(m.content)}`, ); return `\n${lines.join('\n')}\n`; } diff --git a/src/routing.test.ts b/src/routing.test.ts index 93d0cfe..32bfc1f 100644 --- a/src/routing.test.ts +++ b/src/routing.test.ts @@ -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); diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 1e4afce..f6cfa72 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -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; getSessions: () => Record; 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; } @@ -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) { diff --git a/src/whatsapp-auth.ts b/src/whatsapp-auth.ts index b826f18..48545d1 100644 --- a/src/whatsapp-auth.ts +++ b/src/whatsapp-auth.ts @@ -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 { - 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 { }); } -async function connectSocket(phoneNumber?: string, isReconnect = false): Promise { +async function connectSocket( + phoneNumber?: string, + isReconnect = false, +): Promise { 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 { 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'); diff --git a/vitest.skills.config.ts b/vitest.skills.config.ts deleted file mode 100644 index 3be7fcd..0000000 --- a/vitest.skills.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - include: ['.claude/skills/**/tests/*.test.ts'], - }, -});