diff --git a/.changeset/3541-installer-migration-prompt-user-resolution.md b/.changeset/3541-installer-migration-prompt-user-resolution.md index e329b3f37..fbb79c128 100644 --- a/.changeset/3541-installer-migration-prompt-user-resolution.md +++ b/.changeset/3541-installer-migration-prompt-user-resolution.md @@ -3,4 +3,4 @@ type: Fixed pr: 3547 --- -**Installer migration no longer hangs `/gsd-update` on leftover GSD-looking files** — non-TTY installer runs now default-resolve `prompt-user` migration actions by classification (stale SDK build artifacts under `get-shit-done/sdk/{dist,src}/gsd-*` default to `remove`; user-facing `skills/gsd-*/SKILL.md` defaults to `keep`) and log each resolution. Anything that cannot be safely defaulted still blocks, but the error message now groups blocked paths by reason, lists the documented choices, and names the `GSD_INSTALLER_MIGRATION_RESOLVE` env var as the non-interactive resolution surface. (#0) +**Installer migration no longer hangs `/gsd:update` on leftover GSD-looking files** — non-TTY installer runs now default-resolve `prompt-user` migration actions by classification (stale SDK build artifacts under `get-shit-done/sdk/{dist,src}/gsd-*` default to `remove`; user-facing `skills/gsd-*/SKILL.md` defaults to `keep`) and log each resolution. Anything that cannot be safely defaulted still blocks, but the error message now groups blocked paths by reason, lists the documented choices, and names the `GSD_INSTALLER_MIGRATION_RESOLVE` env var as the non-interactive resolution surface. (#3541) diff --git a/get-shit-done/bin/lib/installer-migration-report.cjs b/get-shit-done/bin/lib/installer-migration-report.cjs index f8b772957..700c4645e 100644 --- a/get-shit-done/bin/lib/installer-migration-report.cjs +++ b/get-shit-done/bin/lib/installer-migration-report.cjs @@ -140,6 +140,20 @@ function materializeResolution(action, choice) { return { ...base, type: 'backup-and-remove', backupRelPath: null }; } +function normalizeResolutionChoice(rawValue) { + if (typeof rawValue !== 'string') return null; + const normalized = rawValue.trim().toLowerCase(); + return VALID_CHOICES.includes(normalized) ? normalized : null; +} + +function actionSupportsChoice(action, choice) { + if (!action || !choice) return false; + if (!Array.isArray(action.choices) || action.choices.length === 0) { + return VALID_CHOICES.includes(choice); + } + return action.choices.includes(choice); +} + // 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 @@ -164,14 +178,34 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) { return { result, resolutions: [] }; } + const env = + options && options.env && typeof options.env === 'object' + ? options.env + : process.env; + const envChoice = normalizeResolutionChoice(env && env[RESOLUTION_ENV_VAR]); 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); + let category = null; + let choice = null; + let source = null; + if (envChoice && actionSupportsChoice(action, envChoice)) { + category = 'operator-override'; + choice = envChoice; + source = RESOLUTION_ENV_VAR; + } else { + const classification = classifyPromptUserAction(action); + if (classification) { + category = classification.category; + choice = classification.choice; + source = 'non-tty-default'; + } + } + + if (choice) { + const resolved = materializeResolution(action, choice); // Inject the concrete action into plan.actions so the apply // step picks it up. if (result.plan && Array.isArray(result.plan.actions)) { @@ -179,11 +213,11 @@ function resolveInstallerMigrationPromptsForNonTty(result, options = {}) { } resolutions.push({ relPath: action.relPath, - category: classification.category, - choice: classification.choice, + category, + choice, reason: action.reason, resolvedActionType: resolved.type, - source: 'non-tty-default', + source, }); continue; } diff --git a/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs b/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs index 4a44a42ba..54923ac03 100644 --- a/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs +++ b/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs @@ -198,4 +198,42 @@ describe('#3541: installer migration prompt-user non-TTY resolution', { concurre assert.equal(reasons.length, 1, 'two same-reason paths grouped under one key'); assert.equal(captured.blockedByReason[reasons[0]].length, 2); }); + + test('Test C: non-TTY env override resolves otherwise-unclassified prompt-user actions', () => { + const result = { + blocked: [ + { + type: 'prompt-user', + relPath: 'skills/gsd-custom/SKILL.toml', + reason: 'custom skill metadata requires user decision', + choices: ['keep', 'remove'], + }, + ], + plan: { + actions: [], + blocked: [ + { + type: 'prompt-user', + relPath: 'skills/gsd-custom/SKILL.toml', + reason: 'custom skill metadata requires user decision', + choices: ['keep', 'remove'], + }, + ], + }, + }; + + const resolved = resolveInstallerMigrationPromptsForNonTty(result, { + isTty: false, + env: { GSD_INSTALLER_MIGRATION_RESOLVE: 'keep' }, + }); + + assert.equal(resolved.resolutions.length, 1, 'env override resolves prompt-user action'); + assert.equal(resolved.resolutions[0].choice, 'keep'); + assert.equal(resolved.resolutions[0].source, 'GSD_INSTALLER_MIGRATION_RESOLVE'); + assert.equal(resolved.resolutions[0].category, 'operator-override'); + assert.equal((resolved.result.blocked || []).length, 0); + assert.equal((resolved.result.plan.blocked || []).length, 0); + assert.equal((resolved.result.plan.actions || []).length, 1); + assert.equal(resolved.result.plan.actions[0].type, 'baseline-preserve-user'); + }); });