universal-dev-standards 6.14.0-beta.2 → 6.14.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundled/ai/standards/ai-response-navigation.ai.yaml +43 -3
- package/bundled/ai/standards/checkin-standards.ai.yaml +25 -6
- package/bundled/ai/standards/open-work-tracking.ai.yaml +4 -1
- package/bundled/ai/standards/pipeline-security-gates.ai.yaml +5 -1
- package/bundled/core/ai-response-navigation.md +128 -12
- package/bundled/core/open-work-tracking.md +1 -1
- package/bundled/extensions/frameworks/fat-free-patterns.md +937 -0
- package/bundled/extensions/languages/csharp-style.md +464 -0
- package/bundled/extensions/languages/php/fat-free-patterns.md +915 -0
- package/bundled/extensions/languages/php/php-style.md +693 -0
- package/bundled/extensions/languages/php-style.md +700 -0
- package/bundled/extensions/locales/zh-cn.md +717 -0
- package/bundled/extensions/locales/zh-tw.md +717 -0
- package/bundled/locales/COVERAGE.md +5 -4
- package/bundled/locales/zh-CN/CHANGELOG.md +44 -3
- package/bundled/locales/zh-CN/README.md +2 -2
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/core/ai-response-navigation.md +110 -12
- package/bundled/locales/zh-CN/skills/README.md +1 -0
- package/bundled/locales/zh-CN/skills/comprehension-ladder/SKILL.md +289 -0
- package/bundled/locales/zh-CN/skills/comprehension-ladder/eval-cases.md +261 -0
- package/bundled/locales/zh-TW/CHANGELOG.md +44 -3
- package/bundled/locales/zh-TW/README.md +2 -2
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/ai-response-navigation.md +110 -12
- package/bundled/locales/zh-TW/core/open-work-tracking.md +3 -3
- package/bundled/locales/zh-TW/skills/README.md +1 -0
- package/bundled/locales/zh-TW/skills/comprehension-ladder/SKILL.md +289 -0
- package/bundled/locales/zh-TW/skills/comprehension-ladder/eval-cases.md +261 -0
- package/bundled/skills/README.md +1 -0
- package/bundled/skills/comprehension-ladder/SKILL.md +283 -0
- package/bundled/skills/comprehension-ladder/eval-cases.md +255 -0
- package/package.json +2 -2
- package/src/commands/check.js +9 -0
- package/src/commands/init.js +100 -27
- package/src/commands/uninstall.js +144 -30
- package/src/commands/update.js +62 -3
- package/src/core/install-records.js +191 -0
- package/src/i18n/messages.js +39 -6
- package/src/installers/hooks-installer.js +61 -30
- package/src/installers/integration-installer.js +5 -1
- package/src/installers/standards-installer.js +16 -23
- package/src/reconciler/plan-executor.js +10 -11
- package/src/uninstallers/hook-uninstaller.js +219 -33
- package/src/uninstallers/integration-uninstaller.js +35 -5
- package/src/utils/copier.js +57 -0
- package/src/utils/git-hooks.js +139 -7
- package/src/utils/hasher.js +36 -0
- package/src/utils/integration-generator.js +16 -6
- package/src/utils/legacy-hook-migration.js +112 -0
- package/src/utils/locale.js +19 -0
- package/src/utils/open-work-tracking.mjs +124 -23
- package/standards-registry.json +21 -7
package/src/commands/init.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { createSpinner } from '../utils/spinner.js';
|
|
3
|
-
import {
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs';
|
|
5
4
|
import { join } from 'path';
|
|
6
5
|
import {
|
|
7
6
|
manifestExists as isInitialized,
|
|
@@ -22,13 +21,17 @@ import {
|
|
|
22
21
|
getAgentConfig,
|
|
23
22
|
getAgentDisplayName
|
|
24
23
|
} from '../utils/github.js';
|
|
25
|
-
import { displayLanguageToLocale } from '../utils/locale.js';
|
|
24
|
+
import { displayLanguageToLocale, normalizeRequestedLocale } from '../utils/locale.js';
|
|
25
|
+
import localeUtils from '../utils/locale.js';
|
|
26
|
+
const LOCALE_MAP_FOR_INIT = localeUtils.LOCALE_MAP;
|
|
26
27
|
import { generateReleaseConfig, RELEASE_MODE_LABELS } from '../utils/release-config.js';
|
|
27
28
|
import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
|
|
28
29
|
import { readInstallYaml } from '../utils/config-manager.js';
|
|
29
30
|
import { resolveIntegrationTargetFile } from '../utils/integration-generator.js';
|
|
30
31
|
import { withFileTransaction } from '../utils/transaction.js';
|
|
31
|
-
import {
|
|
32
|
+
import { newRecorder, mkdirTracked, recordFile, persistRecorder, RECORD_KINDS } from '../core/install-records.js';
|
|
33
|
+
import { wireGitHooksPath, getLocalHooksPathConfig, stripLegacyHuskyShLine, ensureShebang, hookRunsUdsCheck, buildPreCommitBlock } from '../utils/git-hooks.js';
|
|
34
|
+
import { migrateLegacyHuskyHook } from '../utils/legacy-hook-migration.js';
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Init command - initialize standards in current project
|
|
@@ -213,7 +216,11 @@ export async function initCommand(options) {
|
|
|
213
216
|
skillHashes: skillsResults.skillHashes,
|
|
214
217
|
commandHashes: skillsResults.commandHashes,
|
|
215
218
|
integrationBlockHashes: integrationResults.integrationBlockHashes,
|
|
216
|
-
manifestIntegrationConfigs: integrationResults.manifestIntegrationConfigs
|
|
219
|
+
manifestIntegrationConfigs: integrationResults.manifestIntegrationConfigs,
|
|
220
|
+
createdIntegrationFiles: [
|
|
221
|
+
...(integrationResults.createdFiles ?? []),
|
|
222
|
+
...(agentsMdResult.created ? [agentsMdResult.path] : [])
|
|
223
|
+
]
|
|
217
224
|
};
|
|
218
225
|
},
|
|
219
226
|
// A half-installed project must never be committed: if any installer
|
|
@@ -258,9 +265,13 @@ export async function initCommand(options) {
|
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
// 4.6. Install enforcement hooks (if --with-hooks)
|
|
268
|
+
// What this step writes is recorded in the manifest (installedArtifacts) so
|
|
269
|
+
// `uds uninstall` can remove exactly it — hook scripts, and the folders UDS had
|
|
270
|
+
// to create for them — and leave everything the adopter put there alone.
|
|
271
|
+
const installRecorder = newRecorder();
|
|
261
272
|
if (config.withHooks) {
|
|
262
273
|
const { installHooks } = await import('../installers/hooks-installer.js');
|
|
263
|
-
const hookResult = installHooks(projectPath);
|
|
274
|
+
const hookResult = installHooks(projectPath, { recorder: installRecorder });
|
|
264
275
|
if (hookResult.installed) {
|
|
265
276
|
console.log(chalk.green(
|
|
266
277
|
` ✓ Enforcement hooks installed (${hookResult.scriptsCount} scripts, `
|
|
@@ -292,7 +303,7 @@ export async function initCommand(options) {
|
|
|
292
303
|
const selectedTools = config.integrations || config.aiTools || [];
|
|
293
304
|
if (selectedTools.includes('codex')) {
|
|
294
305
|
const { installCodexHooks } = await import('../installers/hooks-installer.js');
|
|
295
|
-
const codexResult = installCodexHooks(projectPath);
|
|
306
|
+
const codexResult = installCodexHooks(projectPath, { recorder: installRecorder });
|
|
296
307
|
if (codexResult.installed) {
|
|
297
308
|
console.log(chalk.green(' ✓ Codex Stop hook installed (turn-completion-integrity)'));
|
|
298
309
|
// Measured 2026-09-28 (codex-cli 0.155.1): Codex skips a project hook
|
|
@@ -306,7 +317,7 @@ export async function initCommand(options) {
|
|
|
306
317
|
}
|
|
307
318
|
if (selectedTools.includes('gemini-cli')) {
|
|
308
319
|
const { installGeminiHooks } = await import('../installers/hooks-installer.js');
|
|
309
|
-
const geminiResult = installGeminiHooks(projectPath);
|
|
320
|
+
const geminiResult = installGeminiHooks(projectPath, { recorder: installRecorder });
|
|
310
321
|
if (geminiResult.installed) {
|
|
311
322
|
console.log(chalk.green(' ✓ Gemini CLI AfterAgent hook installed (turn-completion-integrity; Gemini CLI is legacy — see the standard)'));
|
|
312
323
|
} else {
|
|
@@ -315,7 +326,7 @@ export async function initCommand(options) {
|
|
|
315
326
|
}
|
|
316
327
|
if (selectedTools.includes('antigravity')) {
|
|
317
328
|
const { installAgyHooks } = await import('../installers/hooks-installer.js');
|
|
318
|
-
const agyResult = installAgyHooks(projectPath);
|
|
329
|
+
const agyResult = installAgyHooks(projectPath, { recorder: installRecorder });
|
|
319
330
|
if (agyResult.installed) {
|
|
320
331
|
console.log(chalk.green(' ✓ Antigravity CLI Stop hook installed (turn-completion-integrity; .agents/hooks.json)'));
|
|
321
332
|
// Verified 2026-09-29 (agy 1.2.12): single turn, no tool calls, `agy -p`.
|
|
@@ -328,6 +339,15 @@ export async function initCommand(options) {
|
|
|
328
339
|
}
|
|
329
340
|
}
|
|
330
341
|
|
|
342
|
+
// 4.7. Record the integration files UDS created from nothing, and the hook
|
|
343
|
+
// files/folders written above. Their text outside the UDS marker block (the
|
|
344
|
+
// `> Auto-generated by ...` header, the generated sections) is what makes
|
|
345
|
+
// "delete the whole file" provable at uninstall time.
|
|
346
|
+
for (const created of combinedResults.createdIntegrationFiles ?? []) {
|
|
347
|
+
recordFile(installRecorder, projectPath, created, RECORD_KINDS.INTEGRATION_FILE);
|
|
348
|
+
}
|
|
349
|
+
persistRecorder(projectPath, installRecorder);
|
|
350
|
+
|
|
331
351
|
// 5. Setup Pre-commit Hook
|
|
332
352
|
await setupHuskyHook(projectPath);
|
|
333
353
|
|
|
@@ -339,7 +359,7 @@ export async function initCommand(options) {
|
|
|
339
359
|
* - Node.js projects: use husky
|
|
340
360
|
* - Non-Node.js projects: write native .git/hooks/pre-commit
|
|
341
361
|
*/
|
|
342
|
-
export async function setupHuskyHook(projectPath, { allowInTest = false } = {}) {
|
|
362
|
+
export async function setupHuskyHook(projectPath, { allowInTest = false, platform = process.platform } = {}) {
|
|
343
363
|
// 2026-07-30:本函式在跑測試時改寫了 universal-dev-standards 自己
|
|
344
364
|
// ——`npm install --save-dev husky`、改 package.json、建 .husky/pre-commit。
|
|
345
365
|
//
|
|
@@ -359,6 +379,9 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
359
379
|
|
|
360
380
|
const isNodeProject = existsSync(join(projectPath, 'package.json'));
|
|
361
381
|
|
|
382
|
+
// What this function creates, for `uds uninstall` (see core/install-records.js).
|
|
383
|
+
const recorder = newRecorder();
|
|
384
|
+
|
|
362
385
|
if (isNodeProject) {
|
|
363
386
|
console.log(chalk.cyan('Configuring Pre-commit Hook...'));
|
|
364
387
|
|
|
@@ -436,7 +459,7 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
436
459
|
// 3. Ensure .husky directory exists
|
|
437
460
|
if (!existsSync(huskyDir)) {
|
|
438
461
|
try {
|
|
439
|
-
|
|
462
|
+
mkdirTracked(recorder, projectPath, huskyDir);
|
|
440
463
|
} catch (e) {
|
|
441
464
|
console.log(chalk.red(` ✗ Failed to create .husky directory: ${e.message}`));
|
|
442
465
|
return;
|
|
@@ -445,8 +468,10 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
445
468
|
|
|
446
469
|
// 4. Add pre-commit hook content
|
|
447
470
|
const preCommitPath = join(huskyDir, 'pre-commit');
|
|
448
|
-
const udsCmd = 'npx uds check';
|
|
449
471
|
|
|
472
|
+
// Only a hook file UDS creates from nothing can later be proven to be wholly
|
|
473
|
+
// its own; one that already existed is appended to and stays the adopter's.
|
|
474
|
+
const preCommitExisted = existsSync(preCommitPath);
|
|
450
475
|
try {
|
|
451
476
|
// husky v9's own templates carry no shebang, no `_/husky.sh` sourcing
|
|
452
477
|
// (that is v8 syntax, deprecated in v9 and removed in v10). Verified
|
|
@@ -464,6 +489,18 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
464
489
|
// — cheap, harmless on POSIX, and required on Windows. Existing files
|
|
465
490
|
// are appended to, never rewritten — their contents are the adopter's,
|
|
466
491
|
// not ours; ensureShebang only ever prepends a missing first line.
|
|
492
|
+
// A hook an older UDS wrote asks npx for the bare name `uds`, which on the
|
|
493
|
+
// npm registry is an unrelated package (see buildPreCommitBlock). Swap the
|
|
494
|
+
// line UDS itself wrote for the current block before anything else reads
|
|
495
|
+
// the file; a line the adopter edited is left alone and reported below.
|
|
496
|
+
const legacy = existsSync(preCommitPath) ? migrateLegacyHuskyHook(projectPath) : null;
|
|
497
|
+
if (legacy?.state === 'migrated') {
|
|
498
|
+
console.log(chalk.green(' ✓ Replaced the pre-commit line an older UDS wrote (it asked npm to resolve the bare name "uds", which is not this project) with one that runs only the installed UDS CLI'));
|
|
499
|
+
}
|
|
500
|
+
for (const k of legacy?.kept ?? []) {
|
|
501
|
+
console.log(chalk.yellow(` ⚠ .husky/pre-commit line ${k.line} ("${k.text}") asks npm to resolve the bare name "uds", which on the npm registry is an unrelated package. UDS did not write this exact line, so it was left alone — change it to "universal-dev-standards check" (with the CLI installed in this project or on PATH).`));
|
|
502
|
+
}
|
|
503
|
+
|
|
467
504
|
let content = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : '';
|
|
468
505
|
|
|
469
506
|
// A pre-existing file may still carry husky v8's `_/husky.sh` sourcing
|
|
@@ -479,10 +516,10 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
479
516
|
const { content: destripped, removed: hadLegacyLine } = stripLegacyHuskyShLine(content);
|
|
480
517
|
content = destripped;
|
|
481
518
|
|
|
482
|
-
const needsAppend = !content
|
|
519
|
+
const needsAppend = !hookRunsUdsCheck(content);
|
|
483
520
|
if (needsAppend) {
|
|
484
521
|
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
|
485
|
-
content = `${content}${sep}\n
|
|
522
|
+
content = `${content}${sep}\n${buildPreCommitBlock()}`;
|
|
486
523
|
}
|
|
487
524
|
|
|
488
525
|
const { content: shebanged, added: addedShebang } = ensureShebang(content);
|
|
@@ -490,10 +527,22 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
490
527
|
|
|
491
528
|
if (hadLegacyLine || needsAppend || addedShebang) {
|
|
492
529
|
writeFileSync(preCommitPath, content, 'utf-8');
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
530
|
+
if (!preCommitExisted) recordFile(recorder, projectPath, '.husky/pre-commit', RECORD_KINDS.GIT_HOOK);
|
|
531
|
+
// 🔴 Was `execSync(`chmod +x ...`)` inside try/catch. On Windows that
|
|
532
|
+
// spawned cmd.exe, which printed "'chmod' is not recognized as an
|
|
533
|
+
// internal or external command" straight to the user's terminal —
|
|
534
|
+
// execSync inherits the child's stderr by default, so the catch hid the
|
|
535
|
+
// failure from the code and left it loud for the human (adopter report,
|
|
536
|
+
// 6.14.0-beta.2, Windows 11). No shell is needed to change a mode, and
|
|
537
|
+
// Windows has no exec bit to set (git for Windows runs the hook through
|
|
538
|
+
// its own sh regardless), so it is skipped there.
|
|
539
|
+
if (platform !== 'win32') {
|
|
540
|
+
try {
|
|
541
|
+
chmodSync(preCommitPath, 0o755);
|
|
542
|
+
} catch {
|
|
543
|
+
// A filesystem that refuses the mode change (FAT, some mounts) is not
|
|
544
|
+
// a reason to fail the install; git reports a non-executable hook itself.
|
|
545
|
+
}
|
|
497
546
|
}
|
|
498
547
|
}
|
|
499
548
|
|
|
@@ -512,6 +561,8 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
512
561
|
console.log(chalk.red(` ✗ Failed to configure pre-commit hook: ${e.message}`));
|
|
513
562
|
}
|
|
514
563
|
|
|
564
|
+
persistRecorder(projectPath, recorder);
|
|
565
|
+
|
|
515
566
|
// 5. Wire git so the hook ACTUALLY runs — this is the fix. Setting
|
|
516
567
|
// `core.hooksPath` ourselves is what husky's own bootstrap does internally
|
|
517
568
|
// (verified against husky ^9.1.7's source: `git config core.hooksPath
|
|
@@ -567,7 +618,7 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
567
618
|
|
|
568
619
|
if (existsSync(hookPath)) {
|
|
569
620
|
const existingContent = readFileSync(hookPath, 'utf-8');
|
|
570
|
-
if (existingContent
|
|
621
|
+
if (hookRunsUdsCheck(existingContent)) {
|
|
571
622
|
console.log(chalk.gray(' ✓ Pre-commit hook already configured'));
|
|
572
623
|
} else {
|
|
573
624
|
// Never clobber an adopter's own hook (this fix — it used to be
|
|
@@ -576,27 +627,42 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
|
|
|
576
627
|
console.log(chalk.gray(' UDS will not overwrite it. Add "uds check" to it yourself, or remove it and re-run `uds init`.'));
|
|
577
628
|
}
|
|
578
629
|
} else {
|
|
630
|
+
// Fail-closed (XSPEC-444 R1 class sweep). This script used to run the linters
|
|
631
|
+
// as `... 2>/dev/null || true` and `uds check 2>/dev/null || true`, then print
|
|
632
|
+
// "Pre-commit checks passed" — it could not block a commit for any reason.
|
|
633
|
+
// Now: a linter that is installed and fails blocks the commit; one that is not
|
|
634
|
+
// installed is skipped. The UDS check is the same block the husky path writes
|
|
635
|
+
// (buildPreCommitBlock): its exit status stops the commit, and a missing CLI
|
|
636
|
+
// says so and blocks rather than being skipped in silence.
|
|
579
637
|
const hookContent = `#!/bin/sh
|
|
580
638
|
# UDS pre-commit hook
|
|
581
639
|
# Auto-generated by uds init
|
|
582
640
|
|
|
583
641
|
echo "Running UDS pre-commit checks..."
|
|
584
642
|
|
|
585
|
-
#
|
|
643
|
+
# Lint, when the language's linter is installed. A failing linter blocks the commit.
|
|
586
644
|
if [ -f pyproject.toml ] || [ -f requirements.txt ]; then
|
|
587
|
-
python -m ruff
|
|
645
|
+
if python -m ruff --version >/dev/null 2>&1; then
|
|
646
|
+
python -m ruff check . || exit $?
|
|
647
|
+
fi
|
|
588
648
|
elif [ -f go.mod ]; then
|
|
589
|
-
|
|
649
|
+
if command -v go >/dev/null 2>&1; then
|
|
650
|
+
go vet ./... || exit $?
|
|
651
|
+
fi
|
|
590
652
|
elif [ -f Cargo.toml ]; then
|
|
591
|
-
cargo
|
|
653
|
+
if command -v cargo >/dev/null 2>&1; then
|
|
654
|
+
cargo clippy || exit $?
|
|
655
|
+
fi
|
|
592
656
|
fi
|
|
593
657
|
|
|
594
|
-
|
|
595
|
-
uds check 2>/dev/null || true
|
|
596
|
-
|
|
658
|
+
${buildPreCommitBlock({ hookFile: '.git/hooks/pre-commit' })}
|
|
597
659
|
echo "Pre-commit checks passed"
|
|
598
660
|
`;
|
|
599
661
|
writeFileSync(hookPath, hookContent, { mode: 0o755 });
|
|
662
|
+
// Created from nothing (the branch above is "file does not exist"), so the
|
|
663
|
+
// whole file is UDS's — record its hash for `uds uninstall`.
|
|
664
|
+
recordFile(recorder, projectPath, '.git/hooks/pre-commit', RECORD_KINDS.GIT_HOOK);
|
|
665
|
+
persistRecorder(projectPath, recorder);
|
|
600
666
|
console.log(chalk.green(' ✓ Installed .git/hooks/pre-commit (native git hook)'));
|
|
601
667
|
}
|
|
602
668
|
} catch (e) {
|
|
@@ -614,9 +680,16 @@ function buildNonInteractiveConfig(options, detected, projectPath) {
|
|
|
614
680
|
// CLI --locale > .uds/install.yaml locale: > UDS_LOCALE env > LANG > 'en'
|
|
615
681
|
// detectLanguage() handles UDS_LOCALE + LANG fallback internally (P1-CLI-3).
|
|
616
682
|
const installYaml = readInstallYaml(projectPath);
|
|
617
|
-
const
|
|
683
|
+
const requestedLanguage = options.locale
|
|
618
684
|
|| installYaml.locale
|
|
619
685
|
|| detectLanguage(null);
|
|
686
|
+
// Case-insensitive, and an unsupported value is said out loud instead of silently becoming English
|
|
687
|
+
// (dev-platform XSPEC-451 follow-up: `--locale zh-CN` used to install English with exit 0).
|
|
688
|
+
const { displayLanguage: normalizedLanguage, supported } = normalizeRequestedLocale(requestedLanguage);
|
|
689
|
+
const displayLanguage = supported ? normalizedLanguage : 'en';
|
|
690
|
+
if (!supported) {
|
|
691
|
+
console.log(chalk.yellow(`⚠ Unsupported locale "${requestedLanguage}" — installing in English. Supported: ${Object.keys(LOCALE_MAP_FOR_INIT).join(', ')}.`));
|
|
692
|
+
}
|
|
620
693
|
|
|
621
694
|
// Determine AI tools
|
|
622
695
|
const detectedAiTools = Object.keys(detected.aiTools).filter(k => detected.aiTools[k]);
|
|
@@ -3,7 +3,8 @@ import { select, checkbox, confirm } from '@inquirer/prompts';
|
|
|
3
3
|
import { readManifest, manifestExists, writeManifest } from '../core/manifest.js';
|
|
4
4
|
import { t } from '../i18n/messages.js';
|
|
5
5
|
import { uninstallStandards } from '../uninstallers/standards-uninstaller.js';
|
|
6
|
-
import { uninstallHook } from '../uninstallers/hook-uninstaller.js';
|
|
6
|
+
import { uninstallHook, pruneCreatedDirs } from '../uninstallers/hook-uninstaller.js';
|
|
7
|
+
import { forgetRecords } from '../core/install-records.js';
|
|
7
8
|
import { uninstallIntegrations } from '../uninstallers/integration-uninstaller.js';
|
|
8
9
|
import { uninstallSkills } from '../uninstallers/skills-uninstaller.js';
|
|
9
10
|
|
|
@@ -12,6 +13,35 @@ import { uninstallSkills } from '../uninstallers/skills-uninstaller.js';
|
|
|
12
13
|
*/
|
|
13
14
|
const CATEGORIES = ['hooks', 'skills', 'integrations', 'standards'];
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Exit codes. Before these existed every path out of `uninstall` was 0 — a run
|
|
18
|
+
* that removed nothing because the prompt died looked exactly like a completed
|
|
19
|
+
* uninstall to CI and to the adopter's own scripts.
|
|
20
|
+
*/
|
|
21
|
+
const EXIT_NOT_INITIALIZED = 1; // nothing to uninstall / manifest unreadable
|
|
22
|
+
const EXIT_CANNOT_PROMPT = 2; // needed an answer, nobody could give one (same as `uds update`)
|
|
23
|
+
const EXIT_INTERRUPTED = 130; // the prompt was closed before answering (SIGINT convention)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Can anything answer a prompt? Decided BEFORE a prompt is drawn.
|
|
27
|
+
*
|
|
28
|
+
* `uds update` catches ExitPromptError instead (see confirmOrFail there) and
|
|
29
|
+
* explains why it avoids `isTTY`: a wrapped stdin can answer with isTTY unset.
|
|
30
|
+
* That reasoning is sound for a prompt that has already been drawn, but it leaves
|
|
31
|
+
* the visible symptom this command was reported for — the checkbox painted onto a
|
|
32
|
+
* pipe, then a stack trace. Here the question is asked first, and only stdin
|
|
33
|
+
* matters: prompts read from it. An answer that arrives anyway (a test double, a
|
|
34
|
+
* wrapper) is not blocked by ExitPromptError handling further down.
|
|
35
|
+
*/
|
|
36
|
+
function canPrompt() {
|
|
37
|
+
return Boolean(process.stdin.isTTY);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `@inquirer/prompts` throws this when stdin closes or the user presses Ctrl+C. */
|
|
41
|
+
function isPromptClosed(err) {
|
|
42
|
+
return err?.name === 'ExitPromptError' || /force closed the prompt/i.test(err?.message || '');
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
/**
|
|
16
46
|
* Uninstall command - remove UDS standards, integrations, skills, and hooks
|
|
17
47
|
* @param {Object} options - Command options
|
|
@@ -25,19 +55,25 @@ export async function uninstallCommand(options) {
|
|
|
25
55
|
console.log(chalk.bold(msg.title));
|
|
26
56
|
console.log(chalk.gray('─'.repeat(50)));
|
|
27
57
|
|
|
28
|
-
// Check if UDS is initialized
|
|
58
|
+
// Check if UDS is initialized. A project with nothing to uninstall is not a
|
|
59
|
+
// successful uninstall: exit non-zero so a script can tell the two apart.
|
|
29
60
|
if (!manifestExists(projectPath)) {
|
|
30
61
|
console.log(chalk.yellow(common.notInitialized));
|
|
31
62
|
console.log(chalk.gray(` ${common.runInit}`));
|
|
63
|
+
process.exitCode = EXIT_NOT_INITIALIZED;
|
|
32
64
|
return;
|
|
33
65
|
}
|
|
34
66
|
|
|
35
67
|
const manifest = readManifest(projectPath);
|
|
36
68
|
if (!manifest) {
|
|
37
69
|
console.log(chalk.red(common.couldNotReadManifest));
|
|
70
|
+
process.exitCode = EXIT_NOT_INITIALIZED;
|
|
38
71
|
return;
|
|
39
72
|
}
|
|
40
73
|
|
|
74
|
+
const includeUserLevel = options.all || false;
|
|
75
|
+
const dryRun = options.dryRun || false;
|
|
76
|
+
|
|
41
77
|
// Determine which categories to uninstall
|
|
42
78
|
let selectedCategories;
|
|
43
79
|
if (options.all) {
|
|
@@ -48,20 +84,34 @@ export async function uninstallCommand(options) {
|
|
|
48
84
|
selectedCategories = ['skills'];
|
|
49
85
|
} else if (options.integrationsOnly) {
|
|
50
86
|
selectedCategories = ['integrations'];
|
|
51
|
-
} else if (options.yes) {
|
|
52
|
-
// --yes without specific flag → all categories
|
|
87
|
+
} else if (options.yes || dryRun) {
|
|
88
|
+
// --yes without a specific flag → all categories.
|
|
89
|
+
// --dry-run → all categories too, and WITHOUT a prompt: a dry run writes
|
|
90
|
+
// nothing, so there is nothing to ask before showing what a run would do —
|
|
91
|
+
// and its whole use is to be runnable unattended (CI, a pipe). It used to
|
|
92
|
+
// draw the category checkbox even then, and die on it.
|
|
53
93
|
selectedCategories = [...CATEGORIES];
|
|
54
94
|
} else {
|
|
55
95
|
// Interactive: checkbox selection
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
96
|
+
if (!canPrompt()) {
|
|
97
|
+
refuseToPrompt(msg);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
let categories;
|
|
101
|
+
try {
|
|
102
|
+
categories = await checkbox({
|
|
103
|
+
message: msg.selectCategories,
|
|
104
|
+
choices: [
|
|
105
|
+
{ name: `${msg.categoryHooks} (.husky/pre-commit, .claude/settings.json, .codex/hooks.json, .gemini/settings.json, .agents/hooks.json)`, value: 'hooks', checked: true },
|
|
106
|
+
{ name: `${msg.categorySkills} (skills, commands)`, value: 'skills', checked: true },
|
|
107
|
+
{ name: `${msg.categoryIntegrations} (CLAUDE.md, .cursorrules, ...)`, value: 'integrations', checked: true },
|
|
108
|
+
{ name: `${msg.categoryStandards} (.standards/)`, value: 'standards', checked: true }
|
|
109
|
+
]
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (reportClosedPrompt(err, msg)) return;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
65
115
|
|
|
66
116
|
if (categories.length === 0) {
|
|
67
117
|
console.log(chalk.yellow(msg.nothingSelected));
|
|
@@ -70,9 +120,6 @@ export async function uninstallCommand(options) {
|
|
|
70
120
|
selectedCategories = categories;
|
|
71
121
|
}
|
|
72
122
|
|
|
73
|
-
const includeUserLevel = options.all || false;
|
|
74
|
-
const dryRun = options.dryRun || false;
|
|
75
|
-
|
|
76
123
|
// Gather preview: run all uninstallers in dry-run mode to build summary
|
|
77
124
|
const preview = await gatherPreview(projectPath, manifest, selectedCategories, includeUserLevel);
|
|
78
125
|
|
|
@@ -87,10 +134,22 @@ export async function uninstallCommand(options) {
|
|
|
87
134
|
|
|
88
135
|
// Confirm (unless --yes or --dry-run)
|
|
89
136
|
if (!dryRun && !options.yes) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
// Not auto-confirmed when nobody can answer: an unattended shell must not get
|
|
138
|
+
// more permission to delete files than an interactive one is given.
|
|
139
|
+
if (!canPrompt()) {
|
|
140
|
+
refuseToPrompt(msg);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
let confirmed;
|
|
144
|
+
try {
|
|
145
|
+
confirmed = await confirm({
|
|
146
|
+
message: msg.confirmUninstall,
|
|
147
|
+
default: false
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (reportClosedPrompt(err, msg)) return;
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
94
153
|
|
|
95
154
|
if (!confirmed) {
|
|
96
155
|
console.log(chalk.yellow(common.cancelled));
|
|
@@ -106,18 +165,52 @@ export async function uninstallCommand(options) {
|
|
|
106
165
|
|
|
107
166
|
// Execute uninstallation in order: hooks → skills → integrations → standards
|
|
108
167
|
console.log();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
168
|
+
let results;
|
|
169
|
+
try {
|
|
170
|
+
results = await executeUninstall(
|
|
171
|
+
projectPath, manifest, selectedCategories,
|
|
172
|
+
{ includeUserLevel, interactive: !options.yes }
|
|
173
|
+
);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// A per-file question asked mid-run (which is why this cannot be checked up
|
|
176
|
+
// front) was closed. Earlier steps are already done and stay done.
|
|
177
|
+
if (!isPromptClosed(err)) throw err;
|
|
178
|
+
console.log();
|
|
179
|
+
console.log(chalk.red(msg.promptClosedMidRun));
|
|
180
|
+
console.log();
|
|
181
|
+
process.exitCode = EXIT_INTERRUPTED;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
113
184
|
|
|
114
185
|
// Update or remove manifest
|
|
115
|
-
updateManifestAfterUninstall(projectPath, manifest, selectedCategories);
|
|
186
|
+
updateManifestAfterUninstall(projectPath, manifest, selectedCategories, collectDeletedPaths(results));
|
|
116
187
|
|
|
117
188
|
// Display results
|
|
118
189
|
displayResults(results, msg);
|
|
119
190
|
}
|
|
120
191
|
|
|
192
|
+
/** Say why a real run cannot proceed without --yes, and set the exit code. Nothing has been changed. */
|
|
193
|
+
function refuseToPrompt(msg) {
|
|
194
|
+
console.log();
|
|
195
|
+
console.log(chalk.red(msg.cannotPrompt));
|
|
196
|
+
console.log(chalk.gray(` ${msg.cannotPromptHint}`));
|
|
197
|
+
console.log();
|
|
198
|
+
process.exitCode = EXIT_CANNOT_PROMPT;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* If `err` is "the prompt was closed before an answer", report it, set a non-zero
|
|
203
|
+
* exit code and return true. Any other error is the caller's to rethrow.
|
|
204
|
+
*/
|
|
205
|
+
function reportClosedPrompt(err, msg) {
|
|
206
|
+
if (!isPromptClosed(err)) return false;
|
|
207
|
+
console.log();
|
|
208
|
+
console.log(chalk.red(msg.promptClosed));
|
|
209
|
+
console.log();
|
|
210
|
+
process.exitCode = EXIT_INTERRUPTED;
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
121
214
|
/**
|
|
122
215
|
* Gather preview of what will be removed (dry-run all uninstallers)
|
|
123
216
|
*/
|
|
@@ -125,7 +218,7 @@ async function gatherPreview(projectPath, manifest, categories, includeUserLevel
|
|
|
125
218
|
const preview = {};
|
|
126
219
|
|
|
127
220
|
if (categories.includes('hooks')) {
|
|
128
|
-
preview.hooks = uninstallHook(projectPath, { dryRun: true });
|
|
221
|
+
preview.hooks = uninstallHook(projectPath, { dryRun: true, manifest });
|
|
129
222
|
}
|
|
130
223
|
if (categories.includes('skills')) {
|
|
131
224
|
preview.skills = uninstallSkills(projectPath, manifest, { dryRun: true, includeUserLevel });
|
|
@@ -136,10 +229,23 @@ async function gatherPreview(projectPath, manifest, categories, includeUserLevel
|
|
|
136
229
|
if (categories.includes('standards')) {
|
|
137
230
|
preview.standards = uninstallStandards(projectPath, { dryRun: true });
|
|
138
231
|
}
|
|
232
|
+
if (categories.includes('hooks')) {
|
|
233
|
+
// Nothing is deleted yet in a preview, so folders are judged against what the
|
|
234
|
+
// steps above WOULD delete.
|
|
235
|
+
preview.folders = pruneCreatedDirs(projectPath, manifest, {
|
|
236
|
+
dryRun: true,
|
|
237
|
+
plannedDeletions: collectDeletedPaths(preview)
|
|
238
|
+
});
|
|
239
|
+
}
|
|
139
240
|
|
|
140
241
|
return preview;
|
|
141
242
|
}
|
|
142
243
|
|
|
244
|
+
/** Every relative path the given step results deleted (or, for a preview, would delete). */
|
|
245
|
+
function collectDeletedPaths(results) {
|
|
246
|
+
return Object.values(results).flatMap((r) => r.deletedPaths || []);
|
|
247
|
+
}
|
|
248
|
+
|
|
143
249
|
/**
|
|
144
250
|
* Execute actual uninstallation
|
|
145
251
|
*/
|
|
@@ -148,7 +254,7 @@ async function executeUninstall(projectPath, manifest, categories, options) {
|
|
|
148
254
|
const results = {};
|
|
149
255
|
|
|
150
256
|
if (categories.includes('hooks')) {
|
|
151
|
-
results.hooks = uninstallHook(projectPath);
|
|
257
|
+
results.hooks = uninstallHook(projectPath, { manifest });
|
|
152
258
|
}
|
|
153
259
|
if (categories.includes('skills')) {
|
|
154
260
|
results.skills = uninstallSkills(projectPath, manifest, { includeUserLevel });
|
|
@@ -163,6 +269,10 @@ async function executeUninstall(projectPath, manifest, categories, options) {
|
|
|
163
269
|
if (categories.includes('standards')) {
|
|
164
270
|
results.standards = uninstallStandards(projectPath);
|
|
165
271
|
}
|
|
272
|
+
if (categories.includes('hooks')) {
|
|
273
|
+
// After everything else, so a folder that only held UDS files is empty by now.
|
|
274
|
+
results.folders = pruneCreatedDirs(projectPath, manifest);
|
|
275
|
+
}
|
|
166
276
|
|
|
167
277
|
return results;
|
|
168
278
|
}
|
|
@@ -188,7 +298,7 @@ function createIntegrationPromptFn() {
|
|
|
188
298
|
/**
|
|
189
299
|
* Update or remove manifest after uninstall
|
|
190
300
|
*/
|
|
191
|
-
function updateManifestAfterUninstall(projectPath, manifest, categories) {
|
|
301
|
+
function updateManifestAfterUninstall(projectPath, manifest, categories, deletedPaths = []) {
|
|
192
302
|
const removedStandards = categories.includes('standards');
|
|
193
303
|
|
|
194
304
|
if (removedStandards) {
|
|
@@ -196,8 +306,10 @@ function updateManifestAfterUninstall(projectPath, manifest, categories) {
|
|
|
196
306
|
return;
|
|
197
307
|
}
|
|
198
308
|
|
|
199
|
-
// Partial uninstall: update manifest to reflect removed items
|
|
200
|
-
|
|
309
|
+
// Partial uninstall: update manifest to reflect removed items. Install records
|
|
310
|
+
// for the paths just deleted go too — a record for a file that is gone would
|
|
311
|
+
// otherwise "prove" authorship of whatever a user later puts at that path.
|
|
312
|
+
const updated = forgetRecords({ ...manifest }, deletedPaths);
|
|
201
313
|
|
|
202
314
|
if (categories.includes('skills')) {
|
|
203
315
|
updated.skills = {
|
|
@@ -281,6 +393,8 @@ function displayResults(results, msg) {
|
|
|
281
393
|
console.log(chalk.green(msg.uninstallSuccess));
|
|
282
394
|
} else {
|
|
283
395
|
console.log(chalk.yellow(msg.uninstallPartial));
|
|
396
|
+
// "Completed with errors" is not a success to whoever runs this from a script.
|
|
397
|
+
process.exitCode = 1;
|
|
284
398
|
}
|
|
285
399
|
console.log(chalk.gray(` ${msg.removed}: ${totalRemoved} ${msg.skippedLabel}: ${totalSkipped} ${msg.errorsLabel}: ${totalErrors}`));
|
|
286
400
|
}
|