refactor: CI optimization, logging improvements, and codebase formatting (#456)

* fix(db): remove unique constraint on folder to support multi-channel agents

* ci: implement automated skill drift detection and self-healing PRs

* fix: align registration logic with Gavriel's feedback and fix build/test issues from Daniel Mi

* style: conform to prettier standards for CI validation

* test: fix branch naming inconsistency in CI (master vs main)

* fix(ci): robust module resolution by removing file extensions in scripts

* refactor(ci): simplify skill validation by removing redundant combination tests

* style: conform skills-engine to prettier, unify logging in index.ts and cleanup unused imports

* refactor: extract multi-channel DB changes to separate branch

Move channel column, folder suffix logic, and related migrations
to feat/multi-channel-db-v2 for independent review. This PR now
contains only CI/CD optimizations, Prettier formatting, and
logging improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-02-25 23:13:36 +02:00
committed by GitHub
parent bd2e236f73
commit 11c201088b
76 changed files with 2333 additions and 1308 deletions

View File

@@ -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);
});

View File

@@ -1,270 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import { stringify } from 'yaml';
import {
computeOverlapMatrix,
extractOverlapInfo,
generateMatrix,
type SkillOverlapInfo,
} from '../../scripts/generate-ci-matrix.js';
import { SkillManifest } from '../types.js';
import { createTempDir, cleanup } from './test-helpers.js';
function makeManifest(overrides: Partial<SkillManifest> & { skill: string }): SkillManifest {
return {
version: '1.0.0',
description: 'Test skill',
core_version: '1.0.0',
adds: [],
modifies: [],
conflicts: [],
depends: [],
...overrides,
};
}
describe('ci-matrix', () => {
describe('computeOverlapMatrix', () => {
it('detects overlap from shared modifies entries', () => {
const skills: SkillOverlapInfo[] = [
{ name: 'telegram', modifies: ['src/config.ts', 'src/index.ts'], npmDependencies: [] },
{ name: 'discord', modifies: ['src/config.ts', 'src/router.ts'], npmDependencies: [] },
];
const matrix = computeOverlapMatrix(skills);
expect(matrix).toHaveLength(1);
expect(matrix[0].skills).toEqual(['telegram', 'discord']);
expect(matrix[0].reason).toContain('shared modifies');
expect(matrix[0].reason).toContain('src/config.ts');
});
it('returns no entry for non-overlapping skills', () => {
const skills: SkillOverlapInfo[] = [
{ name: 'telegram', modifies: ['src/telegram.ts'], npmDependencies: ['grammy'] },
{ name: 'discord', modifies: ['src/discord.ts'], npmDependencies: ['discord.js'] },
];
const matrix = computeOverlapMatrix(skills);
expect(matrix).toHaveLength(0);
});
it('detects overlap from shared npm dependencies', () => {
const skills: SkillOverlapInfo[] = [
{ name: 'skill-a', modifies: ['src/a.ts'], npmDependencies: ['lodash', 'zod'] },
{ name: 'skill-b', modifies: ['src/b.ts'], npmDependencies: ['zod', 'express'] },
];
const matrix = computeOverlapMatrix(skills);
expect(matrix).toHaveLength(1);
expect(matrix[0].skills).toEqual(['skill-a', 'skill-b']);
expect(matrix[0].reason).toContain('shared npm packages');
expect(matrix[0].reason).toContain('zod');
});
it('reports both modifies and npm overlap in one entry', () => {
const skills: SkillOverlapInfo[] = [
{ name: 'skill-a', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
{ name: 'skill-b', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
];
const matrix = computeOverlapMatrix(skills);
expect(matrix).toHaveLength(1);
expect(matrix[0].reason).toContain('shared modifies');
expect(matrix[0].reason).toContain('shared npm packages');
});
it('handles three skills with pairwise overlaps', () => {
const skills: SkillOverlapInfo[] = [
{ name: 'a', modifies: ['src/config.ts'], npmDependencies: [] },
{ name: 'b', modifies: ['src/config.ts', 'src/router.ts'], npmDependencies: [] },
{ name: 'c', modifies: ['src/router.ts'], npmDependencies: [] },
];
const matrix = computeOverlapMatrix(skills);
// a-b overlap on config.ts, b-c overlap on router.ts, a-c no overlap
expect(matrix).toHaveLength(2);
expect(matrix[0].skills).toEqual(['a', 'b']);
expect(matrix[1].skills).toEqual(['b', 'c']);
});
it('returns empty array for single skill', () => {
const skills: SkillOverlapInfo[] = [
{ name: 'only', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
];
const matrix = computeOverlapMatrix(skills);
expect(matrix).toHaveLength(0);
});
it('returns empty array for no skills', () => {
const matrix = computeOverlapMatrix([]);
expect(matrix).toHaveLength(0);
});
});
describe('extractOverlapInfo', () => {
it('extracts modifies and npm dependencies using dirName', () => {
const manifest = makeManifest({
skill: 'telegram',
modifies: ['src/config.ts'],
structured: {
npm_dependencies: { grammy: '^1.0.0', zod: '^3.0.0' },
},
});
const info = extractOverlapInfo(manifest, 'add-telegram');
expect(info.name).toBe('add-telegram');
expect(info.modifies).toEqual(['src/config.ts']);
expect(info.npmDependencies).toEqual(['grammy', 'zod']);
});
it('handles manifest without structured field', () => {
const manifest = makeManifest({
skill: 'simple',
modifies: ['src/index.ts'],
});
const info = extractOverlapInfo(manifest, 'add-simple');
expect(info.npmDependencies).toEqual([]);
});
it('handles structured without npm_dependencies', () => {
const manifest = makeManifest({
skill: 'env-only',
modifies: [],
structured: {
env_additions: ['MY_VAR'],
},
});
const info = extractOverlapInfo(manifest, 'add-env-only');
expect(info.npmDependencies).toEqual([]);
});
});
describe('generateMatrix with real filesystem', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = createTempDir();
});
afterEach(() => {
cleanup(tmpDir);
});
function createManifestDir(skillsDir: string, name: string, manifest: Record<string, unknown>): void {
const dir = path.join(skillsDir, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify(manifest));
}
it('reads manifests from disk and finds overlaps', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
createManifestDir(skillsDir, 'telegram', {
skill: 'telegram',
version: '1.0.0',
core_version: '1.0.0',
adds: ['src/telegram.ts'],
modifies: ['src/config.ts', 'src/index.ts'],
conflicts: [],
depends: [],
});
createManifestDir(skillsDir, 'discord', {
skill: 'discord',
version: '1.0.0',
core_version: '1.0.0',
adds: ['src/discord.ts'],
modifies: ['src/config.ts', 'src/index.ts'],
conflicts: [],
depends: [],
});
const matrix = generateMatrix(skillsDir);
expect(matrix).toHaveLength(1);
expect(matrix[0].skills).toContain('telegram');
expect(matrix[0].skills).toContain('discord');
});
it('returns empty matrix when skills dir does not exist', () => {
const matrix = generateMatrix(path.join(tmpDir, 'nonexistent'));
expect(matrix).toHaveLength(0);
});
it('returns empty matrix for non-overlapping skills on disk', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
createManifestDir(skillsDir, 'alpha', {
skill: 'alpha',
version: '1.0.0',
core_version: '1.0.0',
adds: ['src/alpha.ts'],
modifies: ['src/alpha-config.ts'],
conflicts: [],
depends: [],
});
createManifestDir(skillsDir, 'beta', {
skill: 'beta',
version: '1.0.0',
core_version: '1.0.0',
adds: ['src/beta.ts'],
modifies: ['src/beta-config.ts'],
conflicts: [],
depends: [],
});
const matrix = generateMatrix(skillsDir);
expect(matrix).toHaveLength(0);
});
it('detects structured npm overlap from disk manifests', () => {
const skillsDir = path.join(tmpDir, '.claude', 'skills');
createManifestDir(skillsDir, 'skill-x', {
skill: 'skill-x',
version: '1.0.0',
core_version: '1.0.0',
adds: [],
modifies: ['src/x.ts'],
conflicts: [],
depends: [],
structured: {
npm_dependencies: { lodash: '^4.0.0' },
},
});
createManifestDir(skillsDir, 'skill-y', {
skill: 'skill-y',
version: '1.0.0',
core_version: '1.0.0',
adds: [],
modifies: ['src/y.ts'],
conflicts: [],
depends: [],
structured: {
npm_dependencies: { lodash: '^4.1.0' },
},
});
const matrix = generateMatrix(skillsDir);
expect(matrix).toHaveLength(1);
expect(matrix[0].reason).toContain('lodash');
});
});
});

View File

@@ -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);

View File

@@ -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}`, {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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,
});

View File

@@ -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 };

View File

@@ -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', () => {

View File

@@ -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();
});
});
});

View File

@@ -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<string, string>;
modifyFiles?: Record<string, string>;
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<string, string>;
modifyFiles?: Record<string, string>;
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');

View File

@@ -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',
},
});

View File

@@ -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);

View File

@@ -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();

View File

@@ -19,7 +19,12 @@ import {
} from './manifest.js';
import { loadPathRemap, resolvePathRemap } from './path-remap.js';
import { mergeFile } from './merge.js';
import { computeFileHash, readState, recordSkillApplication, writeState } from './state.js';
import {
computeFileHash,
readState,
recordSkillApplication,
writeState,
} from './state.js';
import {
mergeDockerComposeServices,
mergeEnvAdditions,
@@ -116,11 +121,17 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
try {
// --- Backup ---
const filesToBackup = [
...manifest.modifies.map((f) => path.join(projectRoot, resolvePathRemap(f, pathRemap))),
...manifest.adds.map((f) => path.join(projectRoot, resolvePathRemap(f, pathRemap))),
...manifest.modifies.map((f) =>
path.join(projectRoot, resolvePathRemap(f, pathRemap)),
),
...manifest.adds.map((f) =>
path.join(projectRoot, resolvePathRemap(f, pathRemap)),
),
...(manifest.file_ops || [])
.filter((op) => op.from)
.map((op) => path.join(projectRoot, resolvePathRemap(op.from!, pathRemap))),
.map((op) =>
path.join(projectRoot, resolvePathRemap(op.from!, pathRemap)),
),
path.join(projectRoot, 'package.json'),
path.join(projectRoot, 'package-lock.json'),
path.join(projectRoot, '.env.example'),
@@ -167,7 +178,12 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
for (const relPath of manifest.modifies) {
const resolvedPath = resolvePathRemap(relPath, pathRemap);
const currentPath = path.join(projectRoot, resolvedPath);
const basePath = path.join(projectRoot, NANOCLAW_DIR, 'base', resolvedPath);
const basePath = path.join(
projectRoot,
NANOCLAW_DIR,
'base',
resolvedPath,
);
// skillPath uses original relPath — skill packages are never mutated
const skillPath = path.join(skillDir, 'modify', relPath);
@@ -259,7 +275,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
for (const f of addedFiles) {
try {
if (fs.existsSync(f)) fs.unlinkSync(f);
} catch { /* best effort */ }
} catch {
/* best effort */
}
}
restoreBackup();
clearBackup();
@@ -311,7 +329,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
for (const f of addedFiles) {
try {
if (fs.existsSync(f)) fs.unlinkSync(f);
} catch { /* best effort */ }
} catch {
/* best effort */
}
}
restoreBackup();
// Re-read state and remove the skill we just recorded
@@ -345,7 +365,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
for (const f of addedFiles) {
try {
if (fs.existsSync(f)) fs.unlinkSync(f);
} catch { /* best effort */ }
} catch {
/* best effort */
}
}
restoreBackup();
clearBackup();
@@ -354,4 +376,3 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
releaseLock();
}
}

View File

@@ -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/',
];

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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 })) {

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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)]

View File

@@ -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;

View File

@@ -27,9 +27,7 @@ function walkDir(dir: string, root: string): string[] {
return results;
}
function collectTrackedFiles(
state: ReturnType<typeof readState>,
): Set<string> {
function collectTrackedFiles(state: ReturnType<typeof readState>): Set<string> {
const tracked = new Set<string>();
for (const skill of state.applied_skills) {
@@ -119,11 +117,7 @@ export async function rebase(newBasePath?: string): Promise<RebaseResult> {
}
// Save combined patch
const patchPath = path.join(
projectRoot,
NANOCLAW_DIR,
'combined.patch',
);
const patchPath = path.join(projectRoot, NANOCLAW_DIR, 'combined.patch');
fs.writeFileSync(patchPath, combinedPatch, 'utf-8');
if (newBasePath) {

View File

@@ -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 {

View File

@@ -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(),
});
}

View File

@@ -229,9 +229,16 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
}
// --- Record path remaps from update metadata ---
const remapFile = path.join(newCorePath, '.nanoclaw-meta', 'path_remap.yaml');
const remapFile = path.join(
newCorePath,
'.nanoclaw-meta',
'path_remap.yaml',
);
if (fs.existsSync(remapFile)) {
const remap = parseYaml(fs.readFileSync(remapFile, 'utf-8')) as Record<string, string>;
const remap = parseYaml(fs.readFileSync(remapFile, 'utf-8')) as Record<
string,
string
>;
if (remap && typeof remap === 'object') {
recordPathRemap(remap);
}
@@ -251,11 +258,16 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
let hasNpmDeps = false;
for (const skill of state.applied_skills) {
const outcomes = skill.structured_outcomes as Record<string, unknown> | undefined;
const outcomes = skill.structured_outcomes as
| Record<string, unknown>
| undefined;
if (!outcomes) continue;
if (outcomes.npm_dependencies) {
Object.assign(allNpmDeps, outcomes.npm_dependencies as Record<string, string>);
Object.assign(
allNpmDeps,
outcomes.npm_dependencies as Record<string, string>,
);
hasNpmDeps = true;
}
if (outcomes.env_additions) {
@@ -292,7 +304,9 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
const skillReapplyResults: Record<string, boolean> = {};
for (const skill of state.applied_skills) {
const outcomes = skill.structured_outcomes as Record<string, unknown> | undefined;
const outcomes = skill.structured_outcomes as
| Record<string, unknown>
| undefined;
if (!outcomes?.test) continue;
const testCmd = outcomes.test as string;
@@ -339,4 +353,3 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
releaseLock();
}
}