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

104
scripts/run-migrations.ts Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env tsx
import { execFileSync, execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { compareSemver } from '../skills-engine/state.js';
// Resolve tsx binary once to avoid npx race conditions across migrations
function resolveTsx(): string {
// Check local node_modules first
const local = path.resolve('node_modules/.bin/tsx');
if (fs.existsSync(local)) return local;
// Fall back to whichever tsx is in PATH
try {
return execSync('which tsx', { encoding: 'utf-8' }).trim();
} catch {
return 'npx'; // last resort
}
}
const tsxBin = resolveTsx();
const fromVersion = process.argv[2];
const toVersion = process.argv[3];
const newCorePath = process.argv[4];
if (!fromVersion || !toVersion || !newCorePath) {
console.error(
'Usage: tsx scripts/run-migrations.ts <from-version> <to-version> <new-core-path>',
);
process.exit(1);
}
interface MigrationResult {
version: string;
success: boolean;
error?: string;
}
const results: MigrationResult[] = [];
// Look for migrations in the new core
const migrationsDir = path.join(newCorePath, 'migrations');
if (!fs.existsSync(migrationsDir)) {
console.log(
JSON.stringify({ migrationsRun: 0, results: [] }, null, 2),
);
process.exit(0);
}
// Discover migration directories (version-named)
const entries = fs.readdirSync(migrationsDir, { withFileTypes: true });
const migrationVersions = entries
.filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
.map((e) => e.name)
.filter(
(v) =>
compareSemver(v, fromVersion) > 0 && compareSemver(v, toVersion) <= 0,
)
.sort(compareSemver);
const projectRoot = process.cwd();
for (const version of migrationVersions) {
const migrationIndex = path.join(migrationsDir, version, 'index.ts');
if (!fs.existsSync(migrationIndex)) {
results.push({
version,
success: false,
error: `Migration ${version}/index.ts not found`,
});
continue;
}
try {
const tsxArgs = tsxBin.endsWith('npx')
? ['tsx', migrationIndex, projectRoot]
: [migrationIndex, projectRoot];
execFileSync(tsxBin, tsxArgs, {
stdio: 'pipe',
cwd: projectRoot,
timeout: 120_000,
});
results.push({ version, success: true });
} catch (err) {
const message =
err instanceof Error ? err.message : String(err);
results.push({ version, success: false, error: message });
}
}
console.log(
JSON.stringify(
{ migrationsRun: results.length, results },
null,
2,
),
);
// Exit with error if any migration failed
if (results.some((r) => !r.success)) {
process.exit(1);
}