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:
@@ -26,10 +26,14 @@ describe('backup', () => {
|
||||
createBackup(['src/app.ts']);
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'app.ts'), 'modified content');
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe('modified content');
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe(
|
||||
'modified content',
|
||||
);
|
||||
|
||||
restoreBackup();
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe('original content');
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'src', 'app.ts'), 'utf-8')).toBe(
|
||||
'original content',
|
||||
);
|
||||
});
|
||||
|
||||
it('createBackup skips missing files without error', () => {
|
||||
@@ -51,7 +55,13 @@ describe('backup', () => {
|
||||
it('createBackup writes tombstone for non-existent files', () => {
|
||||
createBackup(['src/newfile.ts']);
|
||||
|
||||
const tombstone = path.join(tmpDir, '.nanoclaw', 'backup', 'src', 'newfile.ts.tombstone');
|
||||
const tombstone = path.join(
|
||||
tmpDir,
|
||||
'.nanoclaw',
|
||||
'backup',
|
||||
'src',
|
||||
'newfile.ts.tombstone',
|
||||
);
|
||||
expect(fs.existsSync(tombstone)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import {
|
||||
computeOverlapMatrix,
|
||||
extractOverlapInfo,
|
||||
generateMatrix,
|
||||
type SkillOverlapInfo,
|
||||
} from '../../scripts/generate-ci-matrix.js';
|
||||
import { SkillManifest } from '../types.js';
|
||||
import { createTempDir, cleanup } from './test-helpers.js';
|
||||
|
||||
function makeManifest(overrides: Partial<SkillManifest> & { skill: string }): SkillManifest {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
description: 'Test skill',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ci-matrix', () => {
|
||||
describe('computeOverlapMatrix', () => {
|
||||
it('detects overlap from shared modifies entries', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'telegram', modifies: ['src/config.ts', 'src/index.ts'], npmDependencies: [] },
|
||||
{ name: 'discord', modifies: ['src/config.ts', 'src/router.ts'], npmDependencies: [] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].skills).toEqual(['telegram', 'discord']);
|
||||
expect(matrix[0].reason).toContain('shared modifies');
|
||||
expect(matrix[0].reason).toContain('src/config.ts');
|
||||
});
|
||||
|
||||
it('returns no entry for non-overlapping skills', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'telegram', modifies: ['src/telegram.ts'], npmDependencies: ['grammy'] },
|
||||
{ name: 'discord', modifies: ['src/discord.ts'], npmDependencies: ['discord.js'] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects overlap from shared npm dependencies', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'skill-a', modifies: ['src/a.ts'], npmDependencies: ['lodash', 'zod'] },
|
||||
{ name: 'skill-b', modifies: ['src/b.ts'], npmDependencies: ['zod', 'express'] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].skills).toEqual(['skill-a', 'skill-b']);
|
||||
expect(matrix[0].reason).toContain('shared npm packages');
|
||||
expect(matrix[0].reason).toContain('zod');
|
||||
});
|
||||
|
||||
it('reports both modifies and npm overlap in one entry', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'skill-a', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
|
||||
{ name: 'skill-b', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].reason).toContain('shared modifies');
|
||||
expect(matrix[0].reason).toContain('shared npm packages');
|
||||
});
|
||||
|
||||
it('handles three skills with pairwise overlaps', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'a', modifies: ['src/config.ts'], npmDependencies: [] },
|
||||
{ name: 'b', modifies: ['src/config.ts', 'src/router.ts'], npmDependencies: [] },
|
||||
{ name: 'c', modifies: ['src/router.ts'], npmDependencies: [] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
// a-b overlap on config.ts, b-c overlap on router.ts, a-c no overlap
|
||||
expect(matrix).toHaveLength(2);
|
||||
expect(matrix[0].skills).toEqual(['a', 'b']);
|
||||
expect(matrix[1].skills).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
it('returns empty array for single skill', () => {
|
||||
const skills: SkillOverlapInfo[] = [
|
||||
{ name: 'only', modifies: ['src/config.ts'], npmDependencies: ['zod'] },
|
||||
];
|
||||
|
||||
const matrix = computeOverlapMatrix(skills);
|
||||
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array for no skills', () => {
|
||||
const matrix = computeOverlapMatrix([]);
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractOverlapInfo', () => {
|
||||
it('extracts modifies and npm dependencies using dirName', () => {
|
||||
const manifest = makeManifest({
|
||||
skill: 'telegram',
|
||||
modifies: ['src/config.ts'],
|
||||
structured: {
|
||||
npm_dependencies: { grammy: '^1.0.0', zod: '^3.0.0' },
|
||||
},
|
||||
});
|
||||
|
||||
const info = extractOverlapInfo(manifest, 'add-telegram');
|
||||
|
||||
expect(info.name).toBe('add-telegram');
|
||||
expect(info.modifies).toEqual(['src/config.ts']);
|
||||
expect(info.npmDependencies).toEqual(['grammy', 'zod']);
|
||||
});
|
||||
|
||||
it('handles manifest without structured field', () => {
|
||||
const manifest = makeManifest({
|
||||
skill: 'simple',
|
||||
modifies: ['src/index.ts'],
|
||||
});
|
||||
|
||||
const info = extractOverlapInfo(manifest, 'add-simple');
|
||||
|
||||
expect(info.npmDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles structured without npm_dependencies', () => {
|
||||
const manifest = makeManifest({
|
||||
skill: 'env-only',
|
||||
modifies: [],
|
||||
structured: {
|
||||
env_additions: ['MY_VAR'],
|
||||
},
|
||||
});
|
||||
|
||||
const info = extractOverlapInfo(manifest, 'add-env-only');
|
||||
|
||||
expect(info.npmDependencies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMatrix with real filesystem', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function createManifestDir(skillsDir: string, name: string, manifest: Record<string, unknown>): void {
|
||||
const dir = path.join(skillsDir, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify(manifest));
|
||||
}
|
||||
|
||||
it('reads manifests from disk and finds overlaps', () => {
|
||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
||||
|
||||
createManifestDir(skillsDir, 'telegram', {
|
||||
skill: 'telegram',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['src/telegram.ts'],
|
||||
modifies: ['src/config.ts', 'src/index.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
});
|
||||
|
||||
createManifestDir(skillsDir, 'discord', {
|
||||
skill: 'discord',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['src/discord.ts'],
|
||||
modifies: ['src/config.ts', 'src/index.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
});
|
||||
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].skills).toContain('telegram');
|
||||
expect(matrix[0].skills).toContain('discord');
|
||||
});
|
||||
|
||||
it('returns empty matrix when skills dir does not exist', () => {
|
||||
const matrix = generateMatrix(path.join(tmpDir, 'nonexistent'));
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty matrix for non-overlapping skills on disk', () => {
|
||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
||||
|
||||
createManifestDir(skillsDir, 'alpha', {
|
||||
skill: 'alpha',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['src/alpha.ts'],
|
||||
modifies: ['src/alpha-config.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
});
|
||||
|
||||
createManifestDir(skillsDir, 'beta', {
|
||||
skill: 'beta',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['src/beta.ts'],
|
||||
modifies: ['src/beta-config.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
});
|
||||
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
expect(matrix).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects structured npm overlap from disk manifests', () => {
|
||||
const skillsDir = path.join(tmpDir, '.claude', 'skills');
|
||||
|
||||
createManifestDir(skillsDir, 'skill-x', {
|
||||
skill: 'skill-x',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: ['src/x.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
structured: {
|
||||
npm_dependencies: { lodash: '^4.0.0' },
|
||||
},
|
||||
});
|
||||
|
||||
createManifestDir(skillsDir, 'skill-y', {
|
||||
skill: 'skill-y',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: ['src/y.ts'],
|
||||
conflicts: [],
|
||||
depends: [],
|
||||
structured: {
|
||||
npm_dependencies: { lodash: '^4.1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
|
||||
expect(matrix).toHaveLength(1);
|
||||
expect(matrix[0].reason).toContain('lodash');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
cleanup,
|
||||
writeState,
|
||||
} from './test-helpers.js';
|
||||
import { readState, recordSkillApplication, computeFileHash } from '../state.js';
|
||||
import {
|
||||
readState,
|
||||
recordSkillApplication,
|
||||
computeFileHash,
|
||||
} from '../state.js';
|
||||
|
||||
describe('customize', () => {
|
||||
let tmpDir: string;
|
||||
@@ -116,7 +120,13 @@ describe('customize', () => {
|
||||
fs.writeFileSync(trackedFile, 'export const x = 2;');
|
||||
|
||||
// Make the base file a directory to cause diff to exit with code 2
|
||||
const baseFilePath = path.join(tmpDir, '.nanoclaw', 'base', 'src', 'app.ts');
|
||||
const baseFilePath = path.join(
|
||||
tmpDir,
|
||||
'.nanoclaw',
|
||||
'base',
|
||||
'src',
|
||||
'app.ts',
|
||||
);
|
||||
fs.mkdirSync(baseFilePath, { recursive: true });
|
||||
|
||||
expect(() => commitCustomize()).toThrow(/diff error/i);
|
||||
|
||||
@@ -16,13 +16,14 @@ describe('fetch-upstream.sh', () => {
|
||||
upstreamBareDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'nanoclaw-upstream-'),
|
||||
);
|
||||
execSync('git init --bare', { cwd: upstreamBareDir, stdio: 'pipe' });
|
||||
execSync('git init --bare -b main', {
|
||||
cwd: upstreamBareDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Create a working repo, add files, push to the bare repo
|
||||
const seedDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'nanoclaw-seed-'),
|
||||
);
|
||||
execSync('git init', { cwd: seedDir, stdio: 'pipe' });
|
||||
const seedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-seed-'));
|
||||
execSync('git init -b main', { cwd: seedDir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', {
|
||||
cwd: seedDir,
|
||||
stdio: 'pipe',
|
||||
@@ -33,10 +34,7 @@ describe('fetch-upstream.sh', () => {
|
||||
JSON.stringify({ name: 'nanoclaw', version: '2.0.0' }),
|
||||
);
|
||||
fs.mkdirSync(path.join(seedDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(seedDir, 'src/index.ts'),
|
||||
'export const v = 2;',
|
||||
);
|
||||
fs.writeFileSync(path.join(seedDir, 'src/index.ts'), 'export const v = 2;');
|
||||
execSync('git add -A && git commit -m "upstream v2.0.0"', {
|
||||
cwd: seedDir,
|
||||
stdio: 'pipe',
|
||||
@@ -45,29 +43,16 @@ describe('fetch-upstream.sh', () => {
|
||||
cwd: seedDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execSync('git push origin main 2>/dev/null || git push origin master', {
|
||||
execSync('git push origin main', {
|
||||
cwd: seedDir,
|
||||
stdio: 'pipe',
|
||||
shell: '/bin/bash',
|
||||
});
|
||||
|
||||
// Rename the default branch to main in the bare repo if needed
|
||||
try {
|
||||
execSync('git symbolic-ref HEAD refs/heads/main', {
|
||||
cwd: upstreamBareDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} catch {
|
||||
// Already on main
|
||||
}
|
||||
|
||||
fs.rmSync(seedDir, { recursive: true, force: true });
|
||||
|
||||
// Create the "project" repo that will run the script
|
||||
projectDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'nanoclaw-project-'),
|
||||
);
|
||||
execSync('git init', { cwd: projectDir, stdio: 'pipe' });
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-project-'));
|
||||
execSync('git init -b main', { cwd: projectDir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
@@ -97,7 +82,10 @@ describe('fetch-upstream.sh', () => {
|
||||
'.claude/skills/update/scripts',
|
||||
);
|
||||
fs.mkdirSync(skillScriptsDir, { recursive: true });
|
||||
fs.copyFileSync(scriptPath, path.join(skillScriptsDir, 'fetch-upstream.sh'));
|
||||
fs.copyFileSync(
|
||||
scriptPath,
|
||||
path.join(skillScriptsDir, 'fetch-upstream.sh'),
|
||||
);
|
||||
fs.chmodSync(path.join(skillScriptsDir, 'fetch-upstream.sh'), 0o755);
|
||||
});
|
||||
|
||||
@@ -124,7 +112,10 @@ describe('fetch-upstream.sh', () => {
|
||||
);
|
||||
return { stdout, exitCode: 0 };
|
||||
} catch (err: any) {
|
||||
return { stdout: (err.stdout ?? '') + (err.stderr ?? ''), exitCode: err.status ?? 1 };
|
||||
return {
|
||||
stdout: (err.stdout ?? '') + (err.stderr ?? ''),
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,12 +150,12 @@ describe('fetch-upstream.sh', () => {
|
||||
expect(status.TEMP_DIR).toMatch(/^\/tmp\/nanoclaw-update-/);
|
||||
|
||||
// Verify extracted files exist
|
||||
expect(
|
||||
fs.existsSync(path.join(status.TEMP_DIR, 'package.json')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(status.TEMP_DIR, 'src/index.ts')),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(status.TEMP_DIR, 'package.json'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(status.TEMP_DIR, 'src/index.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Cleanup temp dir
|
||||
fs.rmSync(status.TEMP_DIR, { recursive: true, force: true });
|
||||
@@ -172,10 +163,10 @@ describe('fetch-upstream.sh', () => {
|
||||
|
||||
it('uses origin when it points to qwibitai/nanoclaw', () => {
|
||||
// Set origin to a URL containing qwibitai/nanoclaw
|
||||
execSync(
|
||||
`git remote add origin https://github.com/qwibitai/nanoclaw.git`,
|
||||
{ cwd: projectDir, stdio: 'pipe' },
|
||||
);
|
||||
execSync(`git remote add origin https://github.com/qwibitai/nanoclaw.git`, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
// We can't actually fetch from GitHub in tests, but we can verify
|
||||
// it picks the right remote. We'll add a second remote it CAN fetch from.
|
||||
execSync(`git remote add upstream ${upstreamBareDir}`, {
|
||||
|
||||
@@ -9,11 +9,9 @@ function shouldSkipSymlinkTests(err: unknown): boolean {
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(
|
||||
(err as { code?: string }).code === 'EPERM' ||
|
||||
((err as { code?: string }).code === 'EPERM' ||
|
||||
(err as { code?: string }).code === 'EACCES' ||
|
||||
(err as { code?: string }).code === 'ENOSYS'
|
||||
)
|
||||
(err as { code?: string }).code === 'ENOSYS')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,9 +31,10 @@ describe('file-ops', () => {
|
||||
|
||||
it('rename success', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'old.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'rename', from: 'old.ts', to: 'new.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'rename', from: 'old.ts', to: 'new.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'new.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'old.ts'))).toBe(false);
|
||||
@@ -43,9 +42,10 @@ describe('file-ops', () => {
|
||||
|
||||
it('move success', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'file.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'move', from: 'file.ts', to: 'sub/file.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'move', from: 'file.ts', to: 'sub/file.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'sub', 'file.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'file.ts'))).toBe(false);
|
||||
@@ -53,9 +53,10 @@ describe('file-ops', () => {
|
||||
|
||||
it('delete success', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'remove-me.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'delete', path: 'remove-me.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'delete', path: 'remove-me.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'remove-me.ts'))).toBe(false);
|
||||
});
|
||||
@@ -63,43 +64,50 @@ describe('file-ops', () => {
|
||||
it('rename target exists produces error', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'a');
|
||||
fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'b');
|
||||
const result = executeFileOps([
|
||||
{ type: 'rename', from: 'a.ts', to: 'b.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'rename', from: 'a.ts', to: 'b.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('delete missing file produces warning not error', () => {
|
||||
const result = executeFileOps([
|
||||
{ type: 'delete', path: 'nonexistent.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'delete', path: 'nonexistent.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('move creates destination directory', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'move', from: 'src.ts', to: 'deep/nested/dir/src.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'move', from: 'src.ts', to: 'deep/nested/dir/src.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'deep', 'nested', 'dir', 'src.ts'))).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpDir, 'deep', 'nested', 'dir', 'src.ts')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('path escape produces error', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'file.ts'), 'content');
|
||||
const result = executeFileOps([
|
||||
{ type: 'rename', from: 'file.ts', to: '../../escaped.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'rename', from: 'file.ts', to: '../../escaped.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('source missing produces error for rename', () => {
|
||||
const result = executeFileOps([
|
||||
{ type: 'rename', from: 'missing.ts', to: 'new.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'rename', from: 'missing.ts', to: 'new.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -117,12 +125,15 @@ describe('file-ops', () => {
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'source.ts'), 'content');
|
||||
|
||||
const result = executeFileOps([
|
||||
{ type: 'move', from: 'source.ts', to: 'linkdir/pwned.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'move', from: 'source.ts', to: 'linkdir/pwned.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'source.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outsideDir, 'pwned.ts'))).toBe(false);
|
||||
|
||||
@@ -142,12 +153,15 @@ describe('file-ops', () => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = executeFileOps([
|
||||
{ type: 'delete', path: 'linkdir/victim.ts' },
|
||||
], tmpDir);
|
||||
const result = executeFileOps(
|
||||
[{ type: 'delete', path: 'linkdir/victim.ts' }],
|
||||
tmpDir,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes('escapes project root'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(outsideFile)).toBe(true);
|
||||
|
||||
cleanup(outsideDir);
|
||||
|
||||
@@ -53,75 +53,123 @@ describe('manifest', () => {
|
||||
it('throws on missing skill field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
version: '1.0.0', core_version: '1.0.0', adds: [], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing version field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', core_version: '1.0.0', adds: [], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing core_version field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', adds: [], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing adds field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0', modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on missing modifies field', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0', adds: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on path traversal in adds', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0',
|
||||
adds: ['../etc/passwd'], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['../etc/passwd'],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow('Invalid path');
|
||||
});
|
||||
|
||||
it('throws on path traversal in modifies', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0',
|
||||
adds: [], modifies: ['../../secret.ts'],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: [],
|
||||
modifies: ['../../secret.ts'],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow('Invalid path');
|
||||
});
|
||||
|
||||
it('throws on absolute path in adds', () => {
|
||||
const dir = path.join(tmpDir, 'bad-pkg');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'manifest.yaml'), stringify({
|
||||
skill: 'test', version: '1.0.0', core_version: '1.0.0',
|
||||
adds: ['/etc/passwd'], modifies: [],
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'manifest.yaml'),
|
||||
stringify({
|
||||
skill: 'test',
|
||||
version: '1.0.0',
|
||||
core_version: '1.0.0',
|
||||
adds: ['/etc/passwd'],
|
||||
modifies: [],
|
||||
}),
|
||||
);
|
||||
expect(() => readManifest(dir)).toThrow('Invalid path');
|
||||
});
|
||||
|
||||
@@ -230,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);
|
||||
|
||||
@@ -2,7 +2,11 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadPathRemap, recordPathRemap, resolvePathRemap } from '../path-remap.js';
|
||||
import {
|
||||
loadPathRemap,
|
||||
recordPathRemap,
|
||||
resolvePathRemap,
|
||||
} from '../path-remap.js';
|
||||
import { readState, writeState } from '../state.js';
|
||||
import {
|
||||
cleanup,
|
||||
|
||||
@@ -313,10 +313,7 @@ describe('rebase', () => {
|
||||
// Set up current base — short file so changes overlap
|
||||
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
|
||||
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(baseDir, 'src', 'index.ts'),
|
||||
'const x = 1;\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(baseDir, 'src', 'index.ts'), 'const x = 1;\n');
|
||||
|
||||
// Working tree: skill replaces the same line
|
||||
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
|
||||
|
||||
@@ -95,9 +95,7 @@ describe('replay', () => {
|
||||
expect(result.perSkill.telegram.success).toBe(true);
|
||||
|
||||
// Added file should exist
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(true);
|
||||
expect(
|
||||
fs.readFileSync(path.join(tmpDir, 'src', 'telegram.ts'), 'utf-8'),
|
||||
).toBe('telegram code\n');
|
||||
@@ -134,7 +132,8 @@ describe('replay', () => {
|
||||
modifies: ['src/config.ts'],
|
||||
addFiles: { 'src/telegram.ts': 'tg code' },
|
||||
modifyFiles: {
|
||||
'src/config.ts': 'telegram import\nline1\nline2\nline3\nline4\nline5\n',
|
||||
'src/config.ts':
|
||||
'telegram import\nline1\nline2\nline3\nline4\nline5\n',
|
||||
},
|
||||
dirName: 'skill-pkg-tg',
|
||||
});
|
||||
@@ -148,7 +147,8 @@ describe('replay', () => {
|
||||
modifies: ['src/config.ts'],
|
||||
addFiles: { 'src/discord.ts': 'dc code' },
|
||||
modifyFiles: {
|
||||
'src/config.ts': 'line1\nline2\nline3\nline4\nline5\ndiscord import\n',
|
||||
'src/config.ts':
|
||||
'line1\nline2\nline3\nline4\nline5\ndiscord import\n',
|
||||
},
|
||||
dirName: 'skill-pkg-dc',
|
||||
});
|
||||
@@ -164,12 +164,8 @@ describe('replay', () => {
|
||||
expect(result.perSkill.discord.success).toBe(true);
|
||||
|
||||
// Both added files should exist
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'discord.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'telegram.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'src', 'discord.ts'))).toBe(true);
|
||||
|
||||
// Config should have both changes
|
||||
const config = fs.readFileSync(
|
||||
@@ -226,7 +222,11 @@ describe('replay', () => {
|
||||
|
||||
const result = await replaySkills({
|
||||
skills: ['skill-a', 'skill-b', 'skill-c'],
|
||||
skillDirs: { 'skill-a': skill1Dir, 'skill-b': skill2Dir, 'skill-c': skill3Dir },
|
||||
skillDirs: {
|
||||
'skill-a': skill1Dir,
|
||||
'skill-b': skill2Dir,
|
||||
'skill-c': skill3Dir,
|
||||
},
|
||||
projectRoot: tmpDir,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,11 +32,12 @@ describe('run-migrations', () => {
|
||||
to: string,
|
||||
): { stdout: string; exitCode: number } {
|
||||
try {
|
||||
const stdout = execFileSync(
|
||||
tsxBin,
|
||||
[scriptPath, from, to, newCoreDir],
|
||||
{ cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
|
||||
);
|
||||
const stdout = execFileSync(tsxBin, [scriptPath, from, to, newCoreDir], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { stdout, exitCode: 0 };
|
||||
} catch (err: any) {
|
||||
return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 };
|
||||
|
||||
@@ -66,7 +66,9 @@ describe('state', () => {
|
||||
expect(state.applied_skills).toHaveLength(1);
|
||||
expect(state.applied_skills[0].name).toBe('my-skill');
|
||||
expect(state.applied_skills[0].version).toBe('1.0.0');
|
||||
expect(state.applied_skills[0].file_hashes).toEqual({ 'src/foo.ts': 'abc123' });
|
||||
expect(state.applied_skills[0].file_hashes).toEqual({
|
||||
'src/foo.ts': 'abc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('re-applying same skill replaces it', () => {
|
||||
|
||||
@@ -68,10 +68,17 @@ describe('structured', () => {
|
||||
describe('mergeNpmDependencies', () => {
|
||||
it('adds new dependencies', () => {
|
||||
const pkgPath = path.join(tmpDir, 'package.json');
|
||||
fs.writeFileSync(pkgPath, JSON.stringify({
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -203,16 +203,14 @@ describe('uninstall', () => {
|
||||
setupSkillPackage('telegram', {
|
||||
adds: { 'src/telegram.ts': 'tg code\n' },
|
||||
modifies: {
|
||||
'src/config.ts':
|
||||
'telegram import\nline1\nline2\nline3\nline4\nline5\n',
|
||||
'src/config.ts': 'telegram import\nline1\nline2\nline3\nline4\nline5\n',
|
||||
},
|
||||
});
|
||||
|
||||
setupSkillPackage('discord', {
|
||||
adds: { 'src/discord.ts': 'dc code\n' },
|
||||
modifies: {
|
||||
'src/config.ts':
|
||||
'line1\nline2\nline3\nline4\nline5\ndiscord import\n',
|
||||
'src/config.ts': 'line1\nline2\nline3\nline4\nline5\ndiscord import\n',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import { cleanup, createTempDir, initGitRepo, setupNanoclawDir } from './test-helpers.js';
|
||||
import {
|
||||
cleanup,
|
||||
createTempDir,
|
||||
initGitRepo,
|
||||
setupNanoclawDir,
|
||||
} from './test-helpers.js';
|
||||
|
||||
describe('update-core.ts CLI flags', () => {
|
||||
let tmpDir: string;
|
||||
@@ -101,11 +106,12 @@ describe('update-core.ts CLI flags', () => {
|
||||
'package.json': JSON.stringify({ version: '2.0.0' }),
|
||||
});
|
||||
|
||||
const stdout = execFileSync(
|
||||
tsxBin,
|
||||
[scriptPath, '--json', newCoreDir],
|
||||
{ cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
|
||||
);
|
||||
const stdout = execFileSync(tsxBin, [scriptPath, '--json', newCoreDir], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout);
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import { cleanup, createTempDir, initGitRepo, setupNanoclawDir } from './test-helpers.js';
|
||||
import {
|
||||
cleanup,
|
||||
createTempDir,
|
||||
initGitRepo,
|
||||
setupNanoclawDir,
|
||||
} from './test-helpers.js';
|
||||
|
||||
let tmpDir: string;
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
} from './manifest.js';
|
||||
import { loadPathRemap, resolvePathRemap } from './path-remap.js';
|
||||
import { mergeFile } from './merge.js';
|
||||
import { computeFileHash, readState, recordSkillApplication, writeState } from './state.js';
|
||||
import {
|
||||
computeFileHash,
|
||||
readState,
|
||||
recordSkillApplication,
|
||||
writeState,
|
||||
} from './state.js';
|
||||
import {
|
||||
mergeDockerComposeServices,
|
||||
mergeEnvAdditions,
|
||||
@@ -116,11 +121,17 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
try {
|
||||
// --- Backup ---
|
||||
const filesToBackup = [
|
||||
...manifest.modifies.map((f) => path.join(projectRoot, resolvePathRemap(f, pathRemap))),
|
||||
...manifest.adds.map((f) => path.join(projectRoot, resolvePathRemap(f, pathRemap))),
|
||||
...manifest.modifies.map((f) =>
|
||||
path.join(projectRoot, resolvePathRemap(f, pathRemap)),
|
||||
),
|
||||
...manifest.adds.map((f) =>
|
||||
path.join(projectRoot, resolvePathRemap(f, pathRemap)),
|
||||
),
|
||||
...(manifest.file_ops || [])
|
||||
.filter((op) => op.from)
|
||||
.map((op) => path.join(projectRoot, resolvePathRemap(op.from!, pathRemap))),
|
||||
.map((op) =>
|
||||
path.join(projectRoot, resolvePathRemap(op.from!, pathRemap)),
|
||||
),
|
||||
path.join(projectRoot, 'package.json'),
|
||||
path.join(projectRoot, 'package-lock.json'),
|
||||
path.join(projectRoot, '.env.example'),
|
||||
@@ -167,7 +178,12 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
for (const relPath of manifest.modifies) {
|
||||
const resolvedPath = resolvePathRemap(relPath, pathRemap);
|
||||
const currentPath = path.join(projectRoot, resolvedPath);
|
||||
const basePath = path.join(projectRoot, NANOCLAW_DIR, 'base', resolvedPath);
|
||||
const basePath = path.join(
|
||||
projectRoot,
|
||||
NANOCLAW_DIR,
|
||||
'base',
|
||||
resolvedPath,
|
||||
);
|
||||
// skillPath uses original relPath — skill packages are never mutated
|
||||
const skillPath = path.join(skillDir, 'modify', relPath);
|
||||
|
||||
@@ -259,7 +275,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
for (const f of addedFiles) {
|
||||
try {
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
restoreBackup();
|
||||
clearBackup();
|
||||
@@ -311,7 +329,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
for (const f of addedFiles) {
|
||||
try {
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
restoreBackup();
|
||||
// Re-read state and remove the skill we just recorded
|
||||
@@ -345,7 +365,9 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
for (const f of addedFiles) {
|
||||
try {
|
||||
if (fs.existsSync(f)) fs.unlinkSync(f);
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
restoreBackup();
|
||||
clearBackup();
|
||||
@@ -354,4 +376,3 @@ export async function applySkill(skillDir: string): Promise<ApplyResult> {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,4 +8,9 @@ export const SKILLS_SCHEMA_VERSION = '0.1.0';
|
||||
|
||||
// Top-level paths to include in base snapshot and upstream extraction.
|
||||
// Add new entries here when new root-level directories/files need tracking.
|
||||
export const BASE_INCLUDES = ['src/', 'package.json', '.env.example', 'container/'];
|
||||
export const BASE_INCLUDES = [
|
||||
'src/',
|
||||
'package.json',
|
||||
'.env.example',
|
||||
'container/',
|
||||
];
|
||||
|
||||
@@ -5,7 +5,11 @@ import path from 'path';
|
||||
import { parse, stringify } from 'yaml';
|
||||
|
||||
import { BASE_DIR, CUSTOM_DIR } from './constants.js';
|
||||
import { computeFileHash, readState, recordCustomModification } from './state.js';
|
||||
import {
|
||||
computeFileHash,
|
||||
readState,
|
||||
recordCustomModification,
|
||||
} from './state.js';
|
||||
|
||||
interface PendingCustomize {
|
||||
description: string;
|
||||
@@ -76,7 +80,9 @@ export function commitCustomize(): void {
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No files changed during customize session. Nothing to commit.');
|
||||
console.log(
|
||||
'No files changed during customize session. Nothing to commit.',
|
||||
);
|
||||
fs.unlinkSync(pendingPath);
|
||||
return;
|
||||
}
|
||||
@@ -104,7 +110,9 @@ export function commitCustomize(): void {
|
||||
// diff exits 1 when files differ — that's expected
|
||||
combinedPatch += execErr.stdout;
|
||||
} else if (execErr.status === 2) {
|
||||
throw new Error(`diff error for ${relativePath}: diff exited with status 2 (check file permissions or encoding)`);
|
||||
throw new Error(
|
||||
`diff error for ${relativePath}: diff exited with status 2 (check file permissions or encoding)`,
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ function nearestExistingPathOrSymlink(candidateAbsPath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRealPathWithSymlinkAwareAnchor(candidateAbsPath: string): string {
|
||||
function resolveRealPathWithSymlinkAwareAnchor(
|
||||
candidateAbsPath: string,
|
||||
): string {
|
||||
const anchorPath = nearestExistingPathOrSymlink(candidateAbsPath);
|
||||
const anchorStat = fs.lstatSync(anchorPath);
|
||||
let realAnchor: string;
|
||||
@@ -56,7 +58,9 @@ function safePath(projectRoot: string, relativePath: string): string | null {
|
||||
}
|
||||
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const realParent = resolveRealPathWithSymlinkAwareAnchor(path.dirname(resolved));
|
||||
const realParent = resolveRealPathWithSymlinkAwareAnchor(
|
||||
path.dirname(resolved),
|
||||
);
|
||||
if (!isWithinRoot(realRoot, realParent)) {
|
||||
return null;
|
||||
}
|
||||
@@ -64,7 +68,10 @@ function safePath(projectRoot: string, relativePath: string): string | null {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function executeFileOps(ops: FileOperation[], projectRoot: string): FileOpsResult {
|
||||
export function executeFileOps(
|
||||
ops: FileOperation[],
|
||||
projectRoot: string,
|
||||
): FileOpsResult {
|
||||
const result: FileOpsResult = {
|
||||
success: true,
|
||||
executed: [],
|
||||
@@ -122,7 +129,9 @@ export function executeFileOps(ops: FileOperation[], projectRoot: string): FileO
|
||||
return result;
|
||||
}
|
||||
if (!fs.existsSync(delPath)) {
|
||||
result.warnings.push(`delete: file does not exist (skipped): ${op.path}`);
|
||||
result.warnings.push(
|
||||
`delete: file does not exist (skipped): ${op.path}`,
|
||||
);
|
||||
result.executed.push(op);
|
||||
break;
|
||||
}
|
||||
@@ -169,7 +178,9 @@ export function executeFileOps(ops: FileOperation[], projectRoot: string): FileO
|
||||
}
|
||||
|
||||
default: {
|
||||
result.errors.push(`unknown operation type: ${(op as FileOperation).type}`);
|
||||
result.errors.push(
|
||||
`unknown operation type: ${(op as FileOperation).type}`,
|
||||
);
|
||||
result.success = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ export {
|
||||
checkSystemVersion,
|
||||
readManifest,
|
||||
} from './manifest.js';
|
||||
export {
|
||||
isGitRepo,
|
||||
mergeFile,
|
||||
} from './merge.js';
|
||||
export { isGitRepo, mergeFile } from './merge.js';
|
||||
export {
|
||||
loadPathRemap,
|
||||
recordPathRemap,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { BACKUP_DIR, BASE_DIR, BASE_INCLUDES, NANOCLAW_DIR } from './constants.js';
|
||||
import {
|
||||
BACKUP_DIR,
|
||||
BASE_DIR,
|
||||
BASE_INCLUDES,
|
||||
NANOCLAW_DIR,
|
||||
} from './constants.js';
|
||||
import { isGitRepo } from './merge.js';
|
||||
import { writeState } from './state.js';
|
||||
import { SkillState } from './types.js';
|
||||
@@ -68,11 +73,7 @@ export function initNanoclawDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirFiltered(
|
||||
src: string,
|
||||
dest: string,
|
||||
excludes: string[],
|
||||
): void {
|
||||
function copyDirFiltered(src: string, dest: string, excludes: string[]): void {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
|
||||
@@ -40,9 +40,7 @@ export function acquireLock(): () => void {
|
||||
} catch {
|
||||
// Lock file exists — check if it's stale or from a dead process
|
||||
try {
|
||||
const existing: LockInfo = JSON.parse(
|
||||
fs.readFileSync(lockPath, 'utf-8'),
|
||||
);
|
||||
const existing: LockInfo = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
if (!isStale(existing) && isProcessAlive(existing.pid)) {
|
||||
throw new Error(
|
||||
`Operation in progress (pid ${existing.pid}, started ${new Date(existing.timestamp).toISOString()}). If this is stale, delete ${LOCK_FILE}`,
|
||||
@@ -59,11 +57,17 @@ export function acquireLock(): () => void {
|
||||
// Corrupt or unreadable — overwrite
|
||||
}
|
||||
|
||||
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lockInfo), { flag: 'wx' });
|
||||
} catch {
|
||||
throw new Error('Lock contention: another process acquired the lock. Retry.');
|
||||
throw new Error(
|
||||
'Lock contention: another process acquired the lock. Retry.',
|
||||
);
|
||||
}
|
||||
return () => releaseLock();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ export function readManifest(skillDir: string): SkillManifest {
|
||||
const allPaths = [...manifest.adds, ...manifest.modifies];
|
||||
for (const p of allPaths) {
|
||||
if (p.includes('..') || path.isAbsolute(p)) {
|
||||
throw new Error(`Invalid path in manifest: ${p} (must be relative without "..")`);
|
||||
throw new Error(
|
||||
`Invalid path in manifest: ${p} (must be relative without "..")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +80,10 @@ export function checkSystemVersion(manifest: SkillManifest): {
|
||||
if (!manifest.min_skills_system_version) {
|
||||
return { ok: true };
|
||||
}
|
||||
const cmp = compareSemver(manifest.min_skills_system_version, SKILLS_SCHEMA_VERSION);
|
||||
const cmp = compareSemver(
|
||||
manifest.min_skills_system_version,
|
||||
SKILLS_SCHEMA_VERSION,
|
||||
);
|
||||
if (cmp > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -42,11 +42,7 @@ export function migrateExisting(): void {
|
||||
|
||||
if (diff.trim()) {
|
||||
fs.mkdirSync(customDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, patchRelPath),
|
||||
diff,
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(path.join(projectRoot, patchRelPath), diff, 'utf-8');
|
||||
|
||||
// Extract modified file paths from the diff
|
||||
const filesModified = [...diff.matchAll(/^diff -ruN .+ (.+)$/gm)]
|
||||
|
||||
@@ -34,10 +34,7 @@ function toSafeProjectRelativePath(
|
||||
const root = path.resolve(projectRoot);
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const resolved = path.resolve(root, candidatePath);
|
||||
if (
|
||||
!resolved.startsWith(root + path.sep) &&
|
||||
resolved !== root
|
||||
) {
|
||||
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
|
||||
throw new Error(`Path remap escapes project root: "${candidatePath}"`);
|
||||
}
|
||||
if (resolved === root) {
|
||||
@@ -99,9 +96,7 @@ export function resolvePathRemap(
|
||||
): string {
|
||||
const projectRoot = process.cwd();
|
||||
const safeRelPath = toSafeProjectRelativePath(relPath, projectRoot);
|
||||
const remapped =
|
||||
remap[safeRelPath] ??
|
||||
remap[relPath];
|
||||
const remapped = remap[safeRelPath] ?? remap[relPath];
|
||||
|
||||
if (remapped === undefined) {
|
||||
return safeRelPath;
|
||||
|
||||
@@ -27,9 +27,7 @@ function walkDir(dir: string, root: string): string[] {
|
||||
return results;
|
||||
}
|
||||
|
||||
function collectTrackedFiles(
|
||||
state: ReturnType<typeof readState>,
|
||||
): Set<string> {
|
||||
function collectTrackedFiles(state: ReturnType<typeof readState>): Set<string> {
|
||||
const tracked = new Set<string>();
|
||||
|
||||
for (const skill of state.applied_skills) {
|
||||
@@ -119,11 +117,7 @@ export async function rebase(newBasePath?: string): Promise<RebaseResult> {
|
||||
}
|
||||
|
||||
// Save combined patch
|
||||
const patchPath = path.join(
|
||||
projectRoot,
|
||||
NANOCLAW_DIR,
|
||||
'combined.patch',
|
||||
);
|
||||
const patchPath = path.join(projectRoot, NANOCLAW_DIR, 'combined.patch');
|
||||
fs.writeFileSync(patchPath, combinedPatch, 'utf-8');
|
||||
|
||||
if (newBasePath) {
|
||||
|
||||
@@ -4,7 +4,11 @@ import path from 'path';
|
||||
|
||||
import { parse, stringify } from 'yaml';
|
||||
|
||||
import { SKILLS_SCHEMA_VERSION, NANOCLAW_DIR, STATE_FILE } from './constants.js';
|
||||
import {
|
||||
SKILLS_SCHEMA_VERSION,
|
||||
NANOCLAW_DIR,
|
||||
STATE_FILE,
|
||||
} from './constants.js';
|
||||
import { AppliedSkill, CustomModification, SkillState } from './types.js';
|
||||
|
||||
function getStatePath(): string {
|
||||
|
||||
@@ -94,7 +94,9 @@ export function mergeNpmDependencies(
|
||||
|
||||
if (pkg.devDependencies) {
|
||||
pkg.devDependencies = Object.fromEntries(
|
||||
Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b)),
|
||||
Object.entries(pkg.devDependencies).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,5 +194,8 @@ export function mergeDockerComposeServices(
|
||||
}
|
||||
|
||||
export function runNpmInstall(): void {
|
||||
execSync('npm install --legacy-peer-deps', { stdio: 'inherit', cwd: process.cwd() });
|
||||
execSync('npm install --legacy-peer-deps', {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,9 +229,16 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
|
||||
}
|
||||
|
||||
// --- Record path remaps from update metadata ---
|
||||
const remapFile = path.join(newCorePath, '.nanoclaw-meta', 'path_remap.yaml');
|
||||
const remapFile = path.join(
|
||||
newCorePath,
|
||||
'.nanoclaw-meta',
|
||||
'path_remap.yaml',
|
||||
);
|
||||
if (fs.existsSync(remapFile)) {
|
||||
const remap = parseYaml(fs.readFileSync(remapFile, 'utf-8')) as Record<string, string>;
|
||||
const remap = parseYaml(fs.readFileSync(remapFile, 'utf-8')) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
if (remap && typeof remap === 'object') {
|
||||
recordPathRemap(remap);
|
||||
}
|
||||
@@ -251,11 +258,16 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
|
||||
let hasNpmDeps = false;
|
||||
|
||||
for (const skill of state.applied_skills) {
|
||||
const outcomes = skill.structured_outcomes as Record<string, unknown> | undefined;
|
||||
const outcomes = skill.structured_outcomes as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!outcomes) continue;
|
||||
|
||||
if (outcomes.npm_dependencies) {
|
||||
Object.assign(allNpmDeps, outcomes.npm_dependencies as Record<string, string>);
|
||||
Object.assign(
|
||||
allNpmDeps,
|
||||
outcomes.npm_dependencies as Record<string, string>,
|
||||
);
|
||||
hasNpmDeps = true;
|
||||
}
|
||||
if (outcomes.env_additions) {
|
||||
@@ -292,7 +304,9 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
|
||||
const skillReapplyResults: Record<string, boolean> = {};
|
||||
|
||||
for (const skill of state.applied_skills) {
|
||||
const outcomes = skill.structured_outcomes as Record<string, unknown> | undefined;
|
||||
const outcomes = skill.structured_outcomes as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!outcomes?.test) continue;
|
||||
|
||||
const testCmd = outcomes.test as string;
|
||||
@@ -339,4 +353,3 @@ export async function applyUpdate(newCorePath: string): Promise<UpdateResult> {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user