feat: add /update skill for pulling upstream changes (#372)

Interactive skill that guides Claude through fetching upstream NanoClaw,
previewing changes, merging with customizations, running migrations, and
verifying the result. Includes:

- SKILL.md with 9-step update flow
- fetch-upstream.sh: detects remote, fetches, extracts tracked paths
- run-migrations.ts: discovers and runs version-ordered migrations
- post-update.ts: clears backup after conflict resolution
- update-core.ts: adds --json and --preview-only flags
- BASE_INCLUDES moved to constants.ts as single source of truth
- 16 new tests covering fetch, migrations, and CLI flags

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-23 01:03:13 +02:00
committed by GitHub
parent 628d434799
commit 1216b5b99c
10 changed files with 1026 additions and 22 deletions

View File

@@ -0,0 +1,234 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, createTempDir } from './test-helpers.js';
describe('run-migrations', () => {
let tmpDir: string;
let newCoreDir: string;
const scriptPath = path.resolve('scripts/run-migrations.ts');
const tsxBin = path.resolve('node_modules/.bin/tsx');
beforeEach(() => {
tmpDir = createTempDir();
newCoreDir = path.join(tmpDir, 'new-core');
fs.mkdirSync(newCoreDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
function createMigration(version: string, code: string): void {
const migDir = path.join(newCoreDir, 'migrations', version);
fs.mkdirSync(migDir, { recursive: true });
fs.writeFileSync(path.join(migDir, 'index.ts'), code);
}
function runMigrations(
from: string,
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 },
);
return { stdout, exitCode: 0 };
} catch (err: any) {
return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 };
}
}
it('outputs empty results when no migrations directory exists', () => {
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(0);
expect(result.migrationsRun).toBe(0);
expect(result.results).toEqual([]);
});
it('outputs empty results when migrations dir exists but is empty', () => {
fs.mkdirSync(path.join(newCoreDir, 'migrations'), { recursive: true });
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(0);
expect(result.migrationsRun).toBe(0);
});
it('runs migrations in the correct version range', () => {
// Create a marker file when the migration runs
createMigration(
'1.1.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done');
`,
);
createMigration(
'1.2.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.2.0'), 'done');
`,
);
// This one should NOT run (outside range)
createMigration(
'2.1.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-2.1.0'), 'done');
`,
);
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(0);
expect(result.migrationsRun).toBe(2);
expect(result.results[0].version).toBe('1.1.0');
expect(result.results[0].success).toBe(true);
expect(result.results[1].version).toBe('1.2.0');
expect(result.results[1].success).toBe(true);
// Verify the migrations actually ran
expect(fs.existsSync(path.join(tmpDir, 'migrated-1.1.0'))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'migrated-1.2.0'))).toBe(true);
// 2.1.0 is outside range
expect(fs.existsSync(path.join(tmpDir, 'migrated-2.1.0'))).toBe(false);
});
it('excludes the from-version (only runs > from)', () => {
createMigration(
'1.0.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.0.0'), 'done');
`,
);
createMigration(
'1.1.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done');
`,
);
const { stdout } = runMigrations('1.0.0', '1.1.0');
const result = JSON.parse(stdout);
expect(result.migrationsRun).toBe(1);
expect(result.results[0].version).toBe('1.1.0');
// 1.0.0 should NOT have run
expect(fs.existsSync(path.join(tmpDir, 'migrated-1.0.0'))).toBe(false);
});
it('includes the to-version (<= to)', () => {
createMigration(
'2.0.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-2.0.0'), 'done');
`,
);
const { stdout } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(result.migrationsRun).toBe(1);
expect(result.results[0].version).toBe('2.0.0');
expect(result.results[0].success).toBe(true);
});
it('runs migrations in semver ascending order', () => {
// Create them in non-sorted order
for (const v of ['1.3.0', '1.1.0', '1.2.0']) {
createMigration(
v,
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
const log = path.join(root, 'migration-order.log');
const existing = fs.existsSync(log) ? fs.readFileSync(log, 'utf-8') : '';
fs.writeFileSync(log, existing + '${v}\\n');
`,
);
}
const { stdout } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(result.migrationsRun).toBe(3);
expect(result.results.map((r: any) => r.version)).toEqual([
'1.1.0',
'1.2.0',
'1.3.0',
]);
// Verify execution order from the log file
const log = fs.readFileSync(
path.join(tmpDir, 'migration-order.log'),
'utf-8',
);
expect(log.trim()).toBe('1.1.0\n1.2.0\n1.3.0');
});
it('reports failure and exits non-zero when a migration throws', () => {
createMigration(
'1.1.0',
`throw new Error('migration failed intentionally');`,
);
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(1);
expect(result.migrationsRun).toBe(1);
expect(result.results[0].success).toBe(false);
expect(result.results[0].error).toBeDefined();
});
it('ignores non-semver directories in migrations/', () => {
fs.mkdirSync(path.join(newCoreDir, 'migrations', 'README'), {
recursive: true,
});
fs.mkdirSync(path.join(newCoreDir, 'migrations', 'utils'), {
recursive: true,
});
createMigration(
'1.1.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done');
`,
);
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(0);
expect(result.migrationsRun).toBe(1);
expect(result.results[0].version).toBe('1.1.0');
});
});