fix(3541): resolve prompt-user migration actions in non-TTY runs; improve error grouping

Closes #3541

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-15 08:16:28 -04:00
parent a7f0af2ce9
commit c39a7e1f6f
5 changed files with 448 additions and 7 deletions

View File

@@ -1,5 +1,20 @@
'use strict';
// Resolution environment variable surface for #3541 — when the installer
// runs without a TTY (typical /gsd-update path via Claude Code or any
// scripted update), prompt-user migration actions cannot be answered
// interactively. We resolve them by classification:
// - Stale SDK build artifacts (get-shit-done/sdk/{dist,src}/gsd-*):
// default `remove`. Fresh install supplies replacements.
// - User-facing skill anchors (skills/gsd-*/SKILL.md): default `keep`.
// User-owned content is preserved.
// Anything else: fall through to the hard assertion with an improved,
// grouped, actionable error message.
//
// docs/installer-migrations.md#prompt-user-resolution for the spec.
const RESOLUTION_ENV_VAR = 'GSD_INSTALLER_MIGRATION_RESOLVE';
const VALID_CHOICES = ['keep', 'remove'];
function installerMigrationActionLabel(action) {
if (!action || !action.type) return 'skipped';
if (action.type === 'backup-and-remove') return 'backed up and removed';
@@ -83,14 +98,179 @@ function summarizeInstallerMigrationResult(result) {
};
}
// Classify a blocked prompt-user action into one of the safe-default
// categories. Returns null when no safe default applies — caller must
// fall back to the hard assertion / interactive prompt for those.
//
// Stale SDK build artifacts live under get-shit-done/sdk/{dist,src}/
// and are regenerated on every install, so removing them is lossless.
// User-facing skill anchors are the .md files that surface as commands
// to the user — these are user-owned and must be kept.
function classifyPromptUserAction(action) {
const relPath = action && action.relPath;
if (typeof relPath !== 'string' || !relPath) return null;
if (/^get-shit-done\/sdk\/(dist|src)\//.test(relPath)) {
return { category: 'stale-sdk-build-artifact', choice: 'remove' };
}
if (/^skills\/gsd-[^/]+\/SKILL\.md$/.test(relPath)) {
return { category: 'user-facing-skill', choice: 'keep' };
}
return null;
}
// Convert a blocked prompt-user action into a concrete plan action.
// `keep` → baseline-preserve-user (idempotent — already on disk).
// `remove` → backup-and-remove (safe: keeps a rollback copy in the
// migration journal under gsd-migration-journal/<runId>-backups/).
function materializeResolution(action, choice) {
const base = {
migrationId: action.migrationId,
migrationChecksum: action.migrationChecksum,
relPath: action.relPath,
reason: action.reason,
classification: action.classification,
originalHash: action.originalHash || null,
currentHash: action.currentHash || null,
requestedType: 'prompt-user',
};
if (choice === 'keep') {
return { ...base, type: 'baseline-preserve-user' };
}
// 'remove'
return { ...base, type: 'backup-and-remove', backupRelPath: null };
}
// Resolve prompt-user actions when stdin is not a TTY. Mutates the
// passed result so:
// - resolved actions are appended to plan.actions in their concrete
// form (baseline-preserve-user / backup-and-remove);
// - result.blocked and plan.blocked are filtered to actions that
// could NOT be safely defaulted (caller must still handle those).
// Returns { result, resolutions } where `resolutions` is the structured
// log of every defaulted resolution.
function resolveInstallerMigrationPromptsForNonTty(result, options = {}) {
if (!result || typeof result !== 'object') {
return { result, resolutions: [] };
}
const blocked = blockedInstallerMigrationActions(result);
if (blocked.length === 0) {
return { result, resolutions: [] };
}
const isTty = options.isTty === true;
if (isTty) {
// Honour interactive prompting paths (not implemented yet — the
// hard throw is still the right behaviour for TTY runs); resolver
// only fires when the installer cannot interactively ask.
return { result, resolutions: [] };
}
const resolutions = [];
const unresolved = [];
for (const action of blocked) {
if (action && action.type === 'prompt-user') {
const classification = classifyPromptUserAction(action);
if (classification) {
const resolved = materializeResolution(action, classification.choice);
// Inject the concrete action into plan.actions so the apply
// step picks it up.
if (result.plan && Array.isArray(result.plan.actions)) {
result.plan.actions.push(resolved);
}
resolutions.push({
relPath: action.relPath,
category: classification.category,
choice: classification.choice,
reason: action.reason,
resolvedActionType: resolved.type,
source: 'non-tty-default',
});
continue;
}
}
unresolved.push(action);
}
// Mutate both the top-level and plan.blocked surfaces so downstream
// callers (assertInstallerMigrationsUnblocked, summarizers) see the
// post-resolution state.
if (Array.isArray(result.blocked)) {
result.blocked = unresolved;
}
if (result.plan && Array.isArray(result.plan.blocked)) {
result.plan.blocked = unresolved;
}
return { result, resolutions };
}
// Group blocked prompt-user actions by their `reason` so the operator
// sees one summary line per cause instead of N path lines for the
// same underlying issue.
function groupBlockedByReason(blocked) {
const byReason = new Map();
for (const action of blocked) {
const reason = (action && action.reason) || 'no reason given';
if (!byReason.has(reason)) byReason.set(reason, []);
byReason.get(reason).push(action);
}
return byReason;
}
function describeChoicesForActions(blocked) {
const choiceSet = new Set();
for (const action of blocked) {
if (action && Array.isArray(action.choices)) {
for (const choice of action.choices) choiceSet.add(choice);
}
}
if (choiceSet.size === 0) {
for (const fallback of VALID_CHOICES) choiceSet.add(fallback);
}
return [...choiceSet];
}
function buildBlockedErrorMessage(blocked) {
const byReason = groupBlockedByReason(blocked);
const totalFiles = blocked.length;
const choices = describeChoicesForActions(blocked);
const lines = [
`installer migration blocked pending user choice: ${totalFiles} file${totalFiles === 1 ? '' : 's'} need a decision`,
` choices: [${choices.join(', ')}]`,
];
for (const [reason, actions] of byReason) {
lines.push(` - ${actions.length} file${actions.length === 1 ? '' : 's'}: ${reason}`);
// Show up to 3 sample paths so operators can spot which files are
// affected without dumping a thousand-line wall when SDK build
// artifacts leak.
const sample = actions.slice(0, 3).map((a) => a.relPath);
if (sample.length > 0) {
lines.push(` e.g. ${sample.join(', ')}${actions.length > sample.length ? `, ... (+${actions.length - sample.length} more)` : ''}`);
}
}
lines.push(
` resolve non-interactively by setting ${RESOLUTION_ENV_VAR}=<choice> ` +
`(or run the installer in a TTY to be prompted per file).`
);
return lines.join('\n');
}
function assertInstallerMigrationsUnblocked(result) {
const blocked = blockedInstallerMigrationActions(result);
if (blocked.length === 0) return;
const paths = blocked.map((action) => action.relPath).join(', ');
throw new Error(`installer migration blocked pending user choice: ${paths}`);
const message = buildBlockedErrorMessage(blocked);
const error = new Error(message);
error.blocked = blocked;
error.blockedByReason = Object.fromEntries(groupBlockedByReason(blocked));
error.resolutionEnvVar = RESOLUTION_ENV_VAR;
throw error;
}
module.exports = {
RESOLUTION_ENV_VAR,
assertInstallerMigrationsUnblocked,
classifyPromptUserAction,
resolveInstallerMigrationPromptsForNonTty,
summarizeInstallerMigrationResult,
};