221 lines
7.8 KiB
JavaScript
221 lines
7.8 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const BASELINE_MIGRATION_ID = '2026-05-11-first-time-baseline-scan';
|
|
|
|
// Runtime install surfaces must stay aligned with:
|
|
// - docs/installer-migrations.md#runtime-configuration-contract-registry
|
|
// - docs/ARCHITECTURE.md#runtime-install-contract-matrix
|
|
//
|
|
// The registry rows are based on each runtime's upstream loader docs where
|
|
// available. Source-limited rows are intentionally conservative: scan generated
|
|
// files GSD materializes, but do not infer ownership of undocumented host config.
|
|
const RUNTIME_SURFACES = {
|
|
claude: ['get-shit-done', 'commands/gsd', 'skills', 'agents', 'hooks', 'settings.json'],
|
|
codex: ['get-shit-done', 'skills', 'agents', 'hooks', 'config.toml', 'hooks.json'],
|
|
gemini: ['get-shit-done', 'commands/gsd', 'hooks'],
|
|
opencode: ['get-shit-done', 'command', 'skills', 'agents'],
|
|
kilo: ['get-shit-done', 'command', 'skills', 'agents'],
|
|
copilot: ['get-shit-done', 'skills', 'agents'],
|
|
antigravity: ['get-shit-done', 'skills', 'agents'],
|
|
cursor: ['get-shit-done', 'skills', 'agents'],
|
|
windsurf: ['get-shit-done', 'skills', 'agents', 'rules'],
|
|
augment: ['get-shit-done', 'skills', 'agents'],
|
|
trae: ['get-shit-done', 'skills', 'agents', 'rules'],
|
|
qwen: ['get-shit-done', 'skills', 'agents'],
|
|
hermes: ['get-shit-done', 'skills/gsd', 'agents'],
|
|
cline: ['get-shit-done', 'skills', 'agents'],
|
|
codebuddy: ['get-shit-done', 'skills', 'agents'],
|
|
};
|
|
|
|
const COMMON_SURFACES = ['get-shit-done', 'skills', 'agents', 'hooks'];
|
|
const INTERNAL_TOP_LEVEL_NAMES = new Set([
|
|
'gsd-file-manifest.json',
|
|
'gsd-install-state.json',
|
|
'gsd-migration-backups',
|
|
'gsd-migration-journal',
|
|
]);
|
|
const USER_OWNED_PATHS = new Set([
|
|
'get-shit-done/USER-PROFILE.md',
|
|
'commands/gsd/dev-preferences.md',
|
|
'skills/gsd-dev-preferences/SKILL.md',
|
|
]);
|
|
let knownGeneratedAgentNames = null;
|
|
|
|
function normalizeRelPath(relPath) {
|
|
return relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
}
|
|
|
|
function baselineInstallSurfaces(runtime) {
|
|
if (runtime && RUNTIME_SURFACES[runtime]) return RUNTIME_SURFACES[runtime];
|
|
return COMMON_SURFACES;
|
|
}
|
|
|
|
function walkFiles(root, relDir, files) {
|
|
const dir = path.join(root, relDir);
|
|
if (!fs.existsSync(dir)) return;
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const relPath = path.posix.join(relDir, entry.name);
|
|
if (relDir === '' && INTERNAL_TOP_LEVEL_NAMES.has(entry.name)) continue;
|
|
const fullPath = path.join(root, relPath);
|
|
if (entry.isDirectory()) {
|
|
walkFiles(root, relPath, files);
|
|
} else if (entry.isFile()) {
|
|
files.add(normalizeRelPath(relPath));
|
|
}
|
|
}
|
|
}
|
|
|
|
function scanBaselineFiles(configDir, runtime) {
|
|
const relPaths = new Set();
|
|
for (const surface of baselineInstallSurfaces(runtime)) {
|
|
const normalized = normalizeRelPath(surface);
|
|
const fullPath = path.join(configDir, normalized);
|
|
if (!fs.existsSync(fullPath)) continue;
|
|
const stat = fs.statSync(fullPath);
|
|
if (stat.isDirectory()) {
|
|
walkFiles(configDir, normalized, relPaths);
|
|
} else if (stat.isFile() && !INTERNAL_TOP_LEVEL_NAMES.has(normalized)) {
|
|
relPaths.add(normalized);
|
|
}
|
|
}
|
|
return [...relPaths];
|
|
}
|
|
|
|
function isUserOwnedBaselinePath(relPath) {
|
|
if (USER_OWNED_PATHS.has(relPath)) return true;
|
|
const parts = relPath.split('/');
|
|
if (parts[0] === 'skills' && parts[1] && !parts[1].startsWith('gsd-')) return true;
|
|
if (parts[0] === 'agents' && parts[1] && !parts[1].startsWith('gsd-')) return true;
|
|
return false;
|
|
}
|
|
|
|
function listKnownGeneratedAgentNames() {
|
|
if (knownGeneratedAgentNames) return knownGeneratedAgentNames;
|
|
|
|
knownGeneratedAgentNames = new Set();
|
|
const agentsDir = path.resolve(__dirname, '..', '..', '..', '..', 'agents');
|
|
try {
|
|
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
|
if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
|
|
knownGeneratedAgentNames.add(entry.name.replace(/\.md$/, ''));
|
|
}
|
|
}
|
|
} catch {
|
|
// If the source agent directory is unavailable, fail closed and treat
|
|
// GSD-looking agent files as user-choice artifacts.
|
|
}
|
|
|
|
return knownGeneratedAgentNames;
|
|
}
|
|
|
|
function isKnownGeneratedAgentPath(relPath, runtime) {
|
|
const parts = relPath.split('/');
|
|
if (parts.length !== 2 || parts[0] !== 'agents') return false;
|
|
const fileName = parts[1];
|
|
const extension = path.posix.extname(fileName);
|
|
if (extension !== '.md' && !(runtime === 'codex' && extension === '.toml')) return false;
|
|
|
|
const agentName = fileName.slice(0, -extension.length);
|
|
return listKnownGeneratedAgentNames().has(agentName);
|
|
}
|
|
|
|
function isStaleGsdLookingPath(relPath) {
|
|
const baseName = path.posix.basename(relPath);
|
|
if (/^gsd[-_]/.test(baseName)) return true;
|
|
const parts = relPath.split('/');
|
|
if ((parts[0] === 'skills' || parts[0] === 'agents') && parts[1] && parts[1].startsWith('gsd-')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function baselineActionRank(action) {
|
|
if (action.type === 'record-baseline') return 0;
|
|
if (action.type === 'baseline-preserve-user') return 1;
|
|
return 2;
|
|
}
|
|
|
|
module.exports = {
|
|
id: BASELINE_MIGRATION_ID,
|
|
title: 'Record first-time installer migration baseline',
|
|
description: 'Classify existing install surfaces before destructive installer migrations run.',
|
|
introducedIn: '1.50.0',
|
|
scopes: ['global', 'local'],
|
|
destructive: false,
|
|
plan: ({ configDir, runtime, baselineScan, classifyArtifact }) => {
|
|
if (!baselineScan) return [];
|
|
|
|
const actions = [];
|
|
for (const relPath of scanBaselineFiles(configDir, runtime)) {
|
|
// docs/installer-migrations.md#baseline-preserve-user keeps user-owned
|
|
// artifacts out of destructive migration flow; classify later only when
|
|
// ownership is not already known.
|
|
if (isUserOwnedBaselinePath(relPath)) {
|
|
actions.push({
|
|
type: 'baseline-preserve-user',
|
|
relPath,
|
|
reason: 'known user-owned artifact preserved by first-time migration baseline',
|
|
classification: 'user-owned',
|
|
originalHash: null,
|
|
currentHash: null,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const artifact = classifyArtifact(relPath);
|
|
if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') {
|
|
actions.push({
|
|
type: 'record-baseline',
|
|
relPath,
|
|
reason: 'existing manifest-managed file included in first-time migration baseline',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const currentHash = artifact.currentHash;
|
|
if (isKnownGeneratedAgentPath(relPath, runtime)) {
|
|
actions.push({
|
|
type: 'record-baseline',
|
|
relPath,
|
|
reason: 'known installer-generated agent included in first-time migration baseline',
|
|
classification: artifact.classification,
|
|
originalHash: artifact.originalHash,
|
|
currentHash,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (isStaleGsdLookingPath(relPath)) {
|
|
actions.push({
|
|
type: 'prompt-user',
|
|
relPath,
|
|
reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice',
|
|
classification: 'stale-gsd-looking',
|
|
originalHash: artifact.originalHash,
|
|
currentHash,
|
|
prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.',
|
|
choices: ['keep', 'remove'],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
actions.push({
|
|
type: 'baseline-preserve-user',
|
|
relPath,
|
|
reason: 'unknown install-surface file preserved by first-time migration baseline',
|
|
classification: artifact.classification,
|
|
originalHash: artifact.originalHash,
|
|
currentHash,
|
|
});
|
|
}
|
|
|
|
return actions.sort((left, right) =>
|
|
baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath)
|
|
);
|
|
},
|
|
};
|