universal-dev-standards 6.14.0-beta.2 → 6.14.0-beta.3
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/open-work-tracking.ai.yaml +4 -1
- package/bundled/core/open-work-tracking.md +1 -1
- package/bundled/locales/zh-CN/CHANGELOG.md +19 -2
- package/bundled/locales/zh-CN/README.md +1 -1
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/CHANGELOG.md +19 -2
- package/bundled/locales/zh-TW/README.md +1 -1
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/open-work-tracking.md +3 -3
- package/package.json +1 -1
- package/src/commands/check.js +9 -0
- package/src/commands/init.js +71 -18
- package/src/commands/uninstall.js +144 -30
- package/src/commands/update.js +59 -1
- 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/uninstallers/hook-uninstaller.js +216 -32
- package/src/uninstallers/integration-uninstaller.js +35 -5
- package/src/utils/git-hooks.js +135 -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/open-work-tracking.mjs +124 -23
- package/standards-registry.json +7 -7
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
|
|
2
2
|
import { join, basename } from 'path';
|
|
3
|
+
import { proveUnchanged, RECORD_KINDS, RECORDS_KEY, isRealDirectory } from '../core/install-records.js';
|
|
4
|
+
import { stripUdsHookBlock } from '../utils/git-hooks.js';
|
|
3
5
|
import {
|
|
4
6
|
collectHookConfigs, standardsSourceDir, hooksSourceDir,
|
|
5
7
|
CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT, AGY_HOOK_SCRIPT,
|
|
@@ -88,7 +90,7 @@ function applyHooksMap(config, nextMap) {
|
|
|
88
90
|
* difference is which file and which key they look at.
|
|
89
91
|
*/
|
|
90
92
|
function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile }) {
|
|
91
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
93
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
92
94
|
if (!existsSync(configPath)) return result; // nothing installed here — nothing to report
|
|
93
95
|
|
|
94
96
|
let config;
|
|
@@ -106,18 +108,21 @@ function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile })
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
|
|
111
|
+
const updatedConfig = applyHooksMap(config, nextMap);
|
|
112
|
+
const willDeleteFile = deleteEmptyFile && Object.keys(updatedConfig).length === 0;
|
|
113
|
+
// Decided BEFORE the dry-run return: the preview must say the file goes away,
|
|
114
|
+
// and the folder-cleanup step needs to know the folder will be empty.
|
|
109
115
|
if (dryRun) {
|
|
110
|
-
result.removed.push(entryLabel);
|
|
116
|
+
result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
|
|
117
|
+
if (willDeleteFile) result.deletedPaths.push(label);
|
|
111
118
|
return result;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
const updatedConfig = applyHooksMap(config, nextMap);
|
|
115
|
-
const isNowEmpty = Object.keys(updatedConfig).length === 0;
|
|
116
|
-
|
|
117
121
|
try {
|
|
118
|
-
if (
|
|
122
|
+
if (willDeleteFile) {
|
|
119
123
|
unlinkSync(configPath);
|
|
120
124
|
result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
|
|
125
|
+
result.deletedPaths.push(label);
|
|
121
126
|
} else {
|
|
122
127
|
writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2) + '\n');
|
|
123
128
|
result.removed.push(entryLabel);
|
|
@@ -186,7 +191,7 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
186
191
|
const configPath = join(projectPath, '.agents', 'hooks.json');
|
|
187
192
|
const label = '.agents/hooks.json';
|
|
188
193
|
const dryRun = options.dryRun || false;
|
|
189
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
194
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
190
195
|
if (!existsSync(configPath)) return result;
|
|
191
196
|
|
|
192
197
|
let config;
|
|
@@ -242,14 +247,17 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
242
247
|
}
|
|
243
248
|
|
|
244
249
|
const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
|
|
250
|
+
const willDeleteFile = Object.keys(next).length === 0;
|
|
245
251
|
if (dryRun) {
|
|
246
|
-
result.removed.push(entryLabel);
|
|
252
|
+
result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
|
|
253
|
+
if (willDeleteFile) result.deletedPaths.push(label);
|
|
247
254
|
return result;
|
|
248
255
|
}
|
|
249
256
|
try {
|
|
250
|
-
if (
|
|
257
|
+
if (willDeleteFile) {
|
|
251
258
|
unlinkSync(configPath);
|
|
252
259
|
result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
|
|
260
|
+
result.deletedPaths.push(label);
|
|
253
261
|
} else {
|
|
254
262
|
writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
|
|
255
263
|
result.removed.push(entryLabel);
|
|
@@ -260,18 +268,113 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
260
268
|
return result;
|
|
261
269
|
}
|
|
262
270
|
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
// Proof of authorship — "only delete what UDS can prove it wrote, unchanged".
|
|
273
|
+
//
|
|
274
|
+
// A record in `manifest.installedArtifacts` (core/install-records.js) says UDS
|
|
275
|
+
// wrote a file; a matching hash says nobody has changed it since. Both are
|
|
276
|
+
// required to delete a whole file. Anything else is KEPT, and the reason is
|
|
277
|
+
// printed, because "not provably ours" is the honest state of a file from an
|
|
278
|
+
// older UDS (no record) or one an adopter has edited (hash differs) — and
|
|
279
|
+
// guessing "probably ours" is how an adopter's own hook script gets deleted.
|
|
280
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/** Relative paths (forward slashes) of every file under `dir`, or [] if it is not there. */
|
|
283
|
+
function listFiles(dir, rel = '') {
|
|
284
|
+
if (!dir || !existsSync(dir)) return [];
|
|
285
|
+
const out = [];
|
|
286
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
287
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
288
|
+
if (e.isDirectory()) out.push(...listFiles(join(dir, e.name), r));
|
|
289
|
+
else out.push(r);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Remove the hook scripts UDS copied into `scripts/hooks/`.
|
|
296
|
+
*
|
|
297
|
+
* `scripts/hooks/` is a directory UDS scaffolds inside the adopter's project, so
|
|
298
|
+
* an adopter's own scripts can sit beside ours (a test in hook-uninstaller.test.js
|
|
299
|
+
* places one there and it must survive). Only files that have an install record
|
|
300
|
+
* AND still match it are deleted.
|
|
301
|
+
*/
|
|
302
|
+
export function uninstallHookScripts(projectPath, manifest, { dryRun = false, blockedBy = null } = {}) {
|
|
303
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
304
|
+
const files = manifest?.[RECORDS_KEY]?.files || {};
|
|
305
|
+
const recorded = Object.entries(files)
|
|
306
|
+
.filter(([, rec]) => rec && rec.kind === RECORD_KINDS.HOOK_SCRIPT)
|
|
307
|
+
.map(([rel]) => rel)
|
|
308
|
+
.sort();
|
|
309
|
+
|
|
310
|
+
for (const rel of recorded) {
|
|
311
|
+
const abs = join(projectPath, rel);
|
|
312
|
+
if (!existsSync(abs)) continue; // already gone — nothing to remove, nothing to report
|
|
313
|
+
if (blockedBy) {
|
|
314
|
+
result.skipped.push(`${rel} (kept: ${blockedBy})`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const proof = proveUnchanged(manifest, projectPath, rel);
|
|
318
|
+
if (proof.state !== 'proven') {
|
|
319
|
+
result.skipped.push(`${rel} (kept: ${proof.why})`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
if (!dryRun) unlinkSync(abs);
|
|
324
|
+
result.removed.push(`${rel} (deleted — installed by UDS, ${proof.why})`);
|
|
325
|
+
result.deletedPaths.push(rel);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
result.errors.push(`${rel} — ${error.message}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Files that carry the name of a script UDS ships but have no record. Say so once,
|
|
332
|
+
// so a leftover `scripts/hooks/` is an explained decision and not a silent gap.
|
|
333
|
+
const recordedSet = new Set(recorded);
|
|
334
|
+
const shipped = listFiles(hooksSourceDir());
|
|
335
|
+
const unrecorded = shipped
|
|
336
|
+
.map((f) => `scripts/hooks/${f}`)
|
|
337
|
+
.filter((rel) => existsSync(join(projectPath, rel)) && !recordedSet.has(rel));
|
|
338
|
+
if (unrecorded.length > 0) {
|
|
339
|
+
result.skipped.push(
|
|
340
|
+
`scripts/hooks/ (kept: ${unrecorded.length} file${unrecorded.length === 1 ? '' : 's'} carry the name of a UDS hook script, ` +
|
|
341
|
+
'but the manifest has no record that UDS wrote them — installed by an older UDS, or by hand. ' +
|
|
342
|
+
'Delete them yourself if you no longer want them)'
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Line-level fallback, for the one-line form older UDS wrote and for a block whose
|
|
349
|
+
// end marker an adopter removed. The current block is removed whole, by its markers
|
|
350
|
+
// (stripUdsHookBlock) — its inner lines are not individually matchable.
|
|
351
|
+
const UDS_PRECOMMIT_LINE = /uds\s+check|universal-dev-standards\s+check|checkin-standards|^#\s*UDS Standard Check\s*$/;
|
|
352
|
+
const NATIVE_UDS_LINE = /uds\s+check|checkin-standards|UDS pre-commit hook/;
|
|
353
|
+
|
|
354
|
+
/** True when nothing is left but a shebang and blank lines. */
|
|
355
|
+
function nothingButShebang(lines) {
|
|
356
|
+
return lines.every((l) => !l.trim() || /^#!/.test(l.trim()));
|
|
357
|
+
}
|
|
358
|
+
|
|
263
359
|
/**
|
|
264
360
|
* Remove UDS-related lines from .husky/pre-commit, the native
|
|
265
361
|
* .git/hooks/pre-commit fallback, and the enforcement-hook entries UDS wrote
|
|
266
362
|
* into .claude/settings.json, .codex/hooks.json, .gemini/settings.json and
|
|
267
|
-
* .agents/hooks.json
|
|
363
|
+
* .agents/hooks.json; and the hook scripts copied into scripts/hooks/.
|
|
364
|
+
*
|
|
365
|
+
* Whole-file deletion (a hook file, a script) needs proof — see proveUnchanged.
|
|
366
|
+
* A hook file that is not provably wholly UDS's still has its UDS lines removed
|
|
367
|
+
* (each is a line UDS wrote, and the rest of the file is untouched) and the
|
|
368
|
+
* remainder is reported as kept, with the reason.
|
|
369
|
+
*
|
|
268
370
|
* @param {string} projectPath - Project root path
|
|
269
|
-
* @param {Object} options - { dryRun: boolean }
|
|
270
|
-
*
|
|
371
|
+
* @param {Object} options - { dryRun: boolean, manifest: object|null }
|
|
372
|
+
* `manifest` must be read BEFORE `.standards/` is removed; it carries the install records.
|
|
373
|
+
* @returns {Object} { removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }
|
|
271
374
|
*/
|
|
272
375
|
export function uninstallHook(projectPath, options = {}) {
|
|
273
|
-
const { dryRun = false } = options;
|
|
274
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
376
|
+
const { dryRun = false, manifest = null } = options;
|
|
377
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
275
378
|
const hookPath = join(projectPath, '.husky', 'pre-commit');
|
|
276
379
|
|
|
277
380
|
if (!existsSync(hookPath)) {
|
|
@@ -279,12 +382,23 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
279
382
|
} else {
|
|
280
383
|
try {
|
|
281
384
|
const content = readFileSync(hookPath, 'utf-8');
|
|
385
|
+
const proof = proveUnchanged(manifest, projectPath, '.husky/pre-commit');
|
|
282
386
|
const lines = content.split('\n');
|
|
283
|
-
const
|
|
284
|
-
|
|
387
|
+
const filteredLines = stripUdsHookBlock(content).content.split('\n')
|
|
388
|
+
.filter(line => !UDS_PRECOMMIT_LINE.test(line));
|
|
285
389
|
|
|
286
|
-
if (
|
|
390
|
+
if (proof.state === 'proven') {
|
|
391
|
+
// UDS created this file and nobody has touched it: nothing of the adopter's is in it.
|
|
392
|
+
if (!dryRun) unlinkSync(hookPath);
|
|
393
|
+
result.removed.push(`.husky/pre-commit (deleted — created by UDS, ${proof.why})`);
|
|
394
|
+
result.deletedPaths.push('.husky/pre-commit');
|
|
395
|
+
} else if (filteredLines.length === lines.length) {
|
|
287
396
|
result.skipped.push('.husky/pre-commit (no UDS lines found)');
|
|
397
|
+
} else if (nothingButShebang(filteredLines)) {
|
|
398
|
+
// Only UDS's lines (and a shebang) were in it — removing them leaves nothing worth keeping.
|
|
399
|
+
if (!dryRun) unlinkSync(hookPath);
|
|
400
|
+
result.removed.push('.husky/pre-commit (UDS check lines; file removed — nothing else was in it)');
|
|
401
|
+
result.deletedPaths.push('.husky/pre-commit');
|
|
288
402
|
} else if (dryRun) {
|
|
289
403
|
result.removed.push('.husky/pre-commit (UDS check lines)');
|
|
290
404
|
} else {
|
|
@@ -301,25 +415,30 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
301
415
|
if (existsSync(nativeHookPath)) {
|
|
302
416
|
try {
|
|
303
417
|
const content = readFileSync(nativeHookPath, 'utf-8');
|
|
304
|
-
const
|
|
418
|
+
const proof = proveUnchanged(manifest, projectPath, '.git/hooks/pre-commit');
|
|
305
419
|
|
|
306
|
-
if (
|
|
420
|
+
if (proof.state === 'proven') {
|
|
421
|
+
// The whole script is what `uds init` wrote — including the parts no line
|
|
422
|
+
// pattern would ever match (its "Auto-generated by uds init" header, the
|
|
423
|
+
// linter fallbacks, the closing echo). Deleting only the matching lines
|
|
424
|
+
// used to leave that body behind, still executable, still claiming
|
|
425
|
+
// "Pre-commit checks passed".
|
|
426
|
+
if (!dryRun) unlinkSync(nativeHookPath);
|
|
427
|
+
result.removed.push(`.git/hooks/pre-commit (UDS native hook, file removed — ${proof.why})`);
|
|
428
|
+
result.deletedPaths.push('.git/hooks/pre-commit');
|
|
429
|
+
} else if (!NATIVE_UDS_LINE.test(content)) {
|
|
307
430
|
result.skipped.push('.git/hooks/pre-commit (no UDS lines found)');
|
|
308
|
-
} else if (dryRun) {
|
|
309
|
-
result.removed.push('.git/hooks/pre-commit (UDS native hook)');
|
|
310
431
|
} else {
|
|
311
|
-
// Remove only UDS-related lines, keep other hook content
|
|
312
432
|
const lines = content.split('\n');
|
|
313
|
-
const filtered = lines.filter(line => !
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const nonEmpty = filtered.filter(l => l.trim() && l.trim() !== '#!/bin/sh');
|
|
317
|
-
if (nonEmpty.length === 0) {
|
|
318
|
-
unlinkSync(nativeHookPath);
|
|
433
|
+
const filtered = lines.filter(line => !NATIVE_UDS_LINE.test(line));
|
|
434
|
+
if (nothingButShebang(filtered)) {
|
|
435
|
+
if (!dryRun) unlinkSync(nativeHookPath);
|
|
319
436
|
result.removed.push('.git/hooks/pre-commit (UDS native hook, file removed)');
|
|
437
|
+
result.deletedPaths.push('.git/hooks/pre-commit');
|
|
320
438
|
} else {
|
|
321
|
-
writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
|
|
439
|
+
if (!dryRun) writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
|
|
322
440
|
result.removed.push('.git/hooks/pre-commit (UDS lines removed)');
|
|
441
|
+
result.skipped.push(`.git/hooks/pre-commit (kept: the rest of the script — ${proof.why})`);
|
|
323
442
|
}
|
|
324
443
|
}
|
|
325
444
|
} catch (error) {
|
|
@@ -329,16 +448,81 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
329
448
|
|
|
330
449
|
// Enforcement hooks written by installHooks()/installCodexHooks()/installGeminiHooks()
|
|
331
450
|
// — the gap this function used to have entirely (see module doc comment above).
|
|
332
|
-
|
|
451
|
+
const configResults = [
|
|
333
452
|
uninstallClaudeCodeHooks(projectPath, { dryRun }),
|
|
334
453
|
uninstallCodexHooks(projectPath, { dryRun }),
|
|
335
454
|
uninstallGeminiHooks(projectPath, { dryRun }),
|
|
336
455
|
uninstallAgyHooks(projectPath, { dryRun }),
|
|
337
|
-
]
|
|
456
|
+
];
|
|
457
|
+
for (const sub of configResults) {
|
|
338
458
|
result.removed.push(...sub.removed);
|
|
339
459
|
result.skipped.push(...sub.skipped);
|
|
340
460
|
result.errors.push(...sub.errors);
|
|
461
|
+
result.deletedPaths.push(...sub.deletedPaths);
|
|
341
462
|
}
|
|
342
463
|
|
|
464
|
+
// The scripts those entries ran. If a config file could not be cleaned (it still
|
|
465
|
+
// names a script), deleting the script would turn a working hook into a failing one.
|
|
466
|
+
const configFailed = configResults.some((r) => r.errors.length > 0);
|
|
467
|
+
const scripts = uninstallHookScripts(projectPath, manifest, {
|
|
468
|
+
dryRun,
|
|
469
|
+
blockedBy: configFailed ? 'a hook config file could not be cleaned (see the error above) and still runs this script' : null
|
|
470
|
+
});
|
|
471
|
+
result.removed.push(...scripts.removed);
|
|
472
|
+
result.skipped.push(...scripts.skipped);
|
|
473
|
+
result.errors.push(...scripts.errors);
|
|
474
|
+
result.deletedPaths.push(...scripts.deletedPaths);
|
|
475
|
+
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Remove the folders UDS had to create for its files (`.codex/`, `scripts/hooks/`, ...),
|
|
481
|
+
* once they are empty.
|
|
482
|
+
*
|
|
483
|
+
* A folder is removed only if (a) the manifest recorded that UDS created it and
|
|
484
|
+
* (b) nothing is left in it. An empty `.codex/` that the adopter made themselves is
|
|
485
|
+
* indistinguishable by looking; the record is the only thing that tells them apart.
|
|
486
|
+
* A folder that still holds anything — `.agents/rules/style.md` — is kept and
|
|
487
|
+
* reported, so the adopter sees why it stayed.
|
|
488
|
+
*
|
|
489
|
+
* In a dry run nothing has been deleted yet, so `plannedDeletions` (relative paths
|
|
490
|
+
* the run WOULD delete) stands in for the missing deletions when judging "empty".
|
|
491
|
+
*
|
|
492
|
+
* @param {string} projectPath
|
|
493
|
+
* @param {Object|null} manifest - read before `.standards/` was removed
|
|
494
|
+
* @param {{ dryRun?: boolean, plannedDeletions?: string[] }} [options]
|
|
495
|
+
* @returns {{ removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }}
|
|
496
|
+
*/
|
|
497
|
+
export function pruneCreatedDirs(projectPath, manifest, { dryRun = false, plannedDeletions = [] } = {}) {
|
|
498
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
499
|
+
const dirs = manifest?.[RECORDS_KEY]?.createdDirs || [];
|
|
500
|
+
const gone = new Set(plannedDeletions.map((p) => p.replace(/\\/g, '/')));
|
|
501
|
+
// Deepest first: `scripts/hooks` must go before `scripts` can be seen as empty.
|
|
502
|
+
const ordered = [...new Set(dirs)]
|
|
503
|
+
.filter((d) => d && !d.startsWith('..') && !d.startsWith('/'))
|
|
504
|
+
.sort((a, b) => b.split('/').length - a.split('/').length);
|
|
505
|
+
|
|
506
|
+
for (const rel of ordered) {
|
|
507
|
+
const abs = join(projectPath, rel);
|
|
508
|
+
if (!existsSync(abs)) continue;
|
|
509
|
+
if (!isRealDirectory(abs)) {
|
|
510
|
+
result.skipped.push(`${rel}/ (kept: not a plain directory)`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const left = readdirSync(abs).filter((name) => !gone.has(`${rel}/${name}`));
|
|
515
|
+
if (left.length > 0) {
|
|
516
|
+
result.skipped.push(`${rel}/ (kept: created by UDS but not empty — still holds ${left.slice(0, 3).join(', ')}${left.length > 3 ? ', ...' : ''}, which UDS did not write)`);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (!dryRun) rmdirSync(abs);
|
|
520
|
+
gone.add(rel);
|
|
521
|
+
result.removed.push(`${rel}/ (empty folder created by UDS, removed)`);
|
|
522
|
+
result.deletedPaths.push(rel);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
result.errors.push(`${rel}/ — ${error.message}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
343
527
|
return result;
|
|
344
528
|
}
|
|
@@ -3,6 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { extractMarkedContent } from '../utils/integration-generator.js';
|
|
4
4
|
import { SUPPORTED_AI_TOOLS } from '../core/constants.js';
|
|
5
5
|
import { resolveIntegrationFile } from '../core/constants.js';
|
|
6
|
+
import { proveUnchanged } from '../core/install-records.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Get the format for a given integration file name
|
|
@@ -29,7 +30,7 @@ function getFormatForFile(fileName) {
|
|
|
29
30
|
*/
|
|
30
31
|
export async function uninstallIntegrations(projectPath, manifest, options = {}) {
|
|
31
32
|
const { dryRun = false, interactive = false, promptFn = null } = options;
|
|
32
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
33
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
33
34
|
|
|
34
35
|
const integrations = manifest?.integrations || [];
|
|
35
36
|
if (integrations.length === 0) {
|
|
@@ -64,11 +65,27 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
|
|
|
64
65
|
const userContent = (parts.before.trim() + parts.after.trim()).trim();
|
|
65
66
|
const hasUserContent = userContent.length > 0;
|
|
66
67
|
|
|
68
|
+
// Text outside the UDS block is not automatically the adopter's. A file UDS
|
|
69
|
+
// creates from nothing carries generated text there — AGENTS.md's
|
|
70
|
+
// "> Auto-generated by ..." header and sections, CLAUDE.md's leading guidance —
|
|
71
|
+
// and treating all of it as "user content" left an orphaned file pointing at
|
|
72
|
+
// the `.standards/` this very command removes. It is only "not the adopter's"
|
|
73
|
+
// if UDS recorded a hash of it when it created the file and the text still
|
|
74
|
+
// matches; without that proof it stays, and the reason is printed.
|
|
75
|
+
const proof = hasUserContent ? proveUnchanged(manifest, projectPath, fileName) : null;
|
|
76
|
+
const outsideIsUdsOwn = hasUserContent && proof.state === 'proven';
|
|
77
|
+
const keptNote = () => `${fileName} (kept: the text outside the UDS block — ${proof.why})`;
|
|
78
|
+
|
|
67
79
|
if (dryRun) {
|
|
68
|
-
if (hasUserContent) {
|
|
69
|
-
result.removed.push(`${fileName} (remove UDS block, keep user content)`);
|
|
70
|
-
} else {
|
|
80
|
+
if (!hasUserContent) {
|
|
71
81
|
result.removed.push(`${fileName} (delete file)`);
|
|
82
|
+
result.deletedPaths.push(fileName);
|
|
83
|
+
} else if (outsideIsUdsOwn) {
|
|
84
|
+
result.removed.push(`${fileName} (delete file — everything outside the UDS block was generated by UDS)`);
|
|
85
|
+
result.deletedPaths.push(fileName);
|
|
86
|
+
} else {
|
|
87
|
+
result.removed.push(`${fileName} (remove UDS block, keep user content)`);
|
|
88
|
+
result.skipped.push(keptNote());
|
|
72
89
|
}
|
|
73
90
|
continue;
|
|
74
91
|
}
|
|
@@ -77,26 +94,39 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
|
|
|
77
94
|
// 100% UDS-generated → delete entire file
|
|
78
95
|
unlinkSync(filePath);
|
|
79
96
|
result.removed.push(`${fileName} (deleted)`);
|
|
97
|
+
result.deletedPaths.push(fileName);
|
|
98
|
+
} else if (outsideIsUdsOwn) {
|
|
99
|
+
// UDS created this file and its non-block text is still exactly what UDS
|
|
100
|
+
// wrote: there is no adopter content to preserve, so no question to ask.
|
|
101
|
+
unlinkSync(filePath);
|
|
102
|
+
result.removed.push(`${fileName} (deleted — everything outside the UDS block was generated by UDS and is unchanged)`);
|
|
103
|
+
result.deletedPaths.push(fileName);
|
|
80
104
|
} else if (interactive && promptFn) {
|
|
81
105
|
// Ask user what to do
|
|
82
106
|
const action = await promptFn(fileName, hasUserContent);
|
|
83
107
|
if (action === 'delete-file') {
|
|
84
108
|
unlinkSync(filePath);
|
|
85
109
|
result.removed.push(`${fileName} (deleted)`);
|
|
110
|
+
result.deletedPaths.push(fileName);
|
|
86
111
|
} else if (action === 'remove-block') {
|
|
87
112
|
const cleaned = (parts.before + parts.after).trim() + '\n';
|
|
88
113
|
writeFileSync(filePath, cleaned, 'utf-8');
|
|
89
114
|
result.removed.push(`${fileName} (UDS block removed)`);
|
|
115
|
+
result.skipped.push(keptNote());
|
|
90
116
|
} else {
|
|
91
117
|
result.skipped.push(`${fileName} (user skipped)`);
|
|
92
118
|
}
|
|
93
119
|
} else {
|
|
94
|
-
// Non-interactive (--yes): remove UDS block only, preserve
|
|
120
|
+
// Non-interactive (--yes): remove UDS block only, preserve everything else
|
|
95
121
|
const cleaned = (parts.before + parts.after).trim() + '\n';
|
|
96
122
|
writeFileSync(filePath, cleaned, 'utf-8');
|
|
97
123
|
result.removed.push(`${fileName} (UDS block removed)`);
|
|
124
|
+
result.skipped.push(keptNote());
|
|
98
125
|
}
|
|
99
126
|
} catch (error) {
|
|
127
|
+
// A closed prompt is the user stopping the run, not a failure of this file:
|
|
128
|
+
// let the command report it (and exit non-zero) instead of retrying the next file.
|
|
129
|
+
if (error?.name === 'ExitPromptError') throw error;
|
|
100
130
|
result.errors.push(`${fileName} — ${error.message}`);
|
|
101
131
|
}
|
|
102
132
|
}
|
package/src/utils/git-hooks.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* adopters (asiaostrich-telemetry-server, asiaostrich-telemetry-client,
|
|
12
12
|
* machine-setup): all three have `.husky/pre-commit` calling `npx uds check`,
|
|
13
13
|
* none has `core.hooksPath` set, and the hook has never once run.
|
|
14
|
+
* (That `npx uds check` form was itself replaced — see buildPreCommitBlock.)
|
|
14
15
|
*
|
|
15
16
|
* Fix: set `core.hooksPath` directly ourselves. This is exactly what husky's
|
|
16
17
|
* own `index.js` does internally (verified against husky ^9.1.7's source:
|
|
@@ -94,7 +95,7 @@ export function wireGitHooksPath(projectPath, targetRelDir) {
|
|
|
94
95
|
return {
|
|
95
96
|
wired: false,
|
|
96
97
|
reason: `git core.hooksPath is already set to "${configured}"`,
|
|
97
|
-
hint: `UDS will not override an existing core.hooksPath. Confirm the hook script there also runs \`
|
|
98
|
+
hint: `UDS will not override an existing core.hooksPath. Confirm the hook script there also runs \`universal-dev-standards check\` (the installed CLI — not the short name "uds", which on the npm registry is an unrelated package), or switch it yourself: git config --local core.hooksPath ${targetRelDir}`
|
|
98
99
|
};
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -107,7 +108,7 @@ export function wireGitHooksPath(projectPath, targetRelDir) {
|
|
|
107
108
|
return {
|
|
108
109
|
wired: false,
|
|
109
110
|
reason: '.git/hooks/pre-commit already exists',
|
|
110
|
-
hint: 'UDS will not overwrite or bypass an existing .git/hooks/pre-commit. Add "
|
|
111
|
+
hint: 'UDS will not overwrite or bypass an existing .git/hooks/pre-commit. Add a "universal-dev-standards check" line to it manually (it needs the UDS CLI installed in this project or on PATH), or remove it and re-run `uds init`.'
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
114
|
|
|
@@ -126,10 +127,129 @@ export function wireGitHooksPath(projectPath, targetRelDir) {
|
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
// What UDS writes into `.husky/pre-commit`.
|
|
132
|
+
//
|
|
133
|
+
// 🔴 This used to be the single line `npx uds check`. `npx` resolves a bare
|
|
134
|
+
// command name from node_modules/.bin and PATH first, and only when neither has
|
|
135
|
+
// it does it go to the npm registry — and the registry package named `uds` is
|
|
136
|
+
// NOT this project (it is an unrelated package by another maintainer, no
|
|
137
|
+
// `bin`, last published 2022). So a clone with no UDS installed had a commit
|
|
138
|
+
// hook that asked npm for a stranger's package by name. It failed today only
|
|
139
|
+
// because that package has no executable; the day its owner publishes one with a
|
|
140
|
+
// `uds` bin, every adopter's every commit runs the stranger's code.
|
|
141
|
+
//
|
|
142
|
+
// Measured 2026-09-30 against a local fake registry that logs each request
|
|
143
|
+
// (npm 10.9.9, 11.20.0, 12.1.0 — identical): `npx --no-install uds` STILL fetches
|
|
144
|
+
// the package metadata for `uds` (GET /uds), so `--no-install` is not a fix; and
|
|
145
|
+
// `npx --no-install --package=universal-dev-standards uds` does not find a
|
|
146
|
+
// GLOBAL install at all (it goes to the registry for our own name and fails).
|
|
147
|
+
// Neither is usable, so the hook does not involve npm at all: it looks in this
|
|
148
|
+
// project's node_modules/.bin, then on PATH, and it looks for the bin named
|
|
149
|
+
// after the full package name — `universal-dev-standards` is a name only this
|
|
150
|
+
// project can publish, `uds` is a name anyone's package can also claim.
|
|
151
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/** First line of the block UDS writes (also the marker older UDS versions wrote). */
|
|
154
|
+
export const UDS_HOOK_MARKER = '# UDS Standard Check';
|
|
155
|
+
/** Last line of the block — lets uninstall remove the whole block, not guess line by line. */
|
|
156
|
+
export const UDS_HOOK_END_MARKER = '# End UDS Standard Check';
|
|
157
|
+
/** The bin every UDS release has shipped since the rename; named after the package on purpose. */
|
|
158
|
+
export const UDS_BIN_NAME = 'universal-dev-standards';
|
|
159
|
+
/** A hook line that runs the UDS check — the old short form or the current full-name form. */
|
|
160
|
+
export const UDS_CHECK_COMMAND_RE = /\b(?:uds|universal-dev-standards)\s+check\b/;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Any place a hook script runs the bare name `uds` through a package runner
|
|
164
|
+
* (npx, bunx, pnpm dlx, yarn dlx, npm exec ...). Broader than the two exact lines UDS
|
|
165
|
+
* ever wrote (see legacy-hook-migration.js): this finds lines to TELL the
|
|
166
|
+
* adopter about, not lines UDS may change.
|
|
167
|
+
*/
|
|
168
|
+
export const BARE_UDS_RUNNER_RE = /\b(?:npx|bunx|pnpx|pnpm\s+dlx|yarn\s+dlx|npm\s+exec|npm\s+x)\s+(?:-\S+\s+)*uds\b/;
|
|
169
|
+
|
|
170
|
+
/** Does this hook script still run the bare name `uds` through a package runner? */
|
|
171
|
+
export function hasBareUdsRunner(content) {
|
|
172
|
+
return typeof content === 'string' && content.split('\n').some((l) => BARE_UDS_RUNNER_RE.test(l));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Does hook script `content` run the UDS check, in either the old or the current form? */
|
|
176
|
+
export function hookRunsUdsCheck(content) {
|
|
177
|
+
return typeof content === 'string' && UDS_CHECK_COMMAND_RE.test(content);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The block `uds init` appends to `.husky/pre-commit` (and `uds update` swaps in
|
|
182
|
+
* for the old one-liner). POSIX sh; runs in git-bash on Windows.
|
|
183
|
+
*
|
|
184
|
+
* - The subshell keeps the PATH change from leaking into the adopter's own
|
|
185
|
+
* commands in the same hook file.
|
|
186
|
+
* - `|| exit $?` makes both outcomes stop the commit even when the adopter has
|
|
187
|
+
* put more commands after this block. Without it the "not installed" branch
|
|
188
|
+
* would be a silent skip of the check, which is what this must never be.
|
|
189
|
+
* - Not installed: say what is missing and how to fix it, exit non-zero. It
|
|
190
|
+
* neither skips the check nor downloads anything.
|
|
191
|
+
*
|
|
192
|
+
* @param {{args?: string}} [opts] extra arguments to keep on the check command
|
|
193
|
+
* (e.g. `--standard checkin-standards`, which older UDS versions wrote).
|
|
194
|
+
* @returns {string} the block, LF line endings, ending with a newline
|
|
195
|
+
*/
|
|
196
|
+
export function buildPreCommitBlock({ args = '' } = {}) {
|
|
197
|
+
const cmd = args ? `${UDS_BIN_NAME} check ${args}` : `${UDS_BIN_NAME} check`;
|
|
198
|
+
return [
|
|
199
|
+
UDS_HOOK_MARKER,
|
|
200
|
+
'# Runs the UDS CLI installed in this project (node_modules/.bin) or on PATH.',
|
|
201
|
+
'# It never asks npm to fetch anything: the short name "uds" on the npm registry',
|
|
202
|
+
'# belongs to an unrelated package, and npx would download and run it.',
|
|
203
|
+
'(',
|
|
204
|
+
' PATH="$PWD/node_modules/.bin:$PATH"',
|
|
205
|
+
` if command -v ${UDS_BIN_NAME} >/dev/null 2>&1; then`,
|
|
206
|
+
` ${cmd}`,
|
|
207
|
+
' exit $?',
|
|
208
|
+
' fi',
|
|
209
|
+
` echo "[UDS] Pre-commit check cannot run: the UDS CLI (${UDS_BIN_NAME}) is not installed." >&2`,
|
|
210
|
+
` echo "[UDS] Install it: npm install --save-dev ${UDS_BIN_NAME} (or: npm install -g ${UDS_BIN_NAME})" >&2`,
|
|
211
|
+
' echo "[UDS] This commit is blocked until it is installed, or until you remove this block from .husky/pre-commit." >&2',
|
|
212
|
+
' exit 1',
|
|
213
|
+
') || exit $?',
|
|
214
|
+
UDS_HOOK_END_MARKER,
|
|
215
|
+
''
|
|
216
|
+
].join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Remove the block buildPreCommitBlock() writes — from the marker line through
|
|
221
|
+
* the end-marker line, inclusive — and nothing else. A block whose end marker is
|
|
222
|
+
* gone (the adopter cut into it) is left alone: what remains of it is no longer
|
|
223
|
+
* provably UDS's, and the caller's line-level fallback deals with the lines it can name.
|
|
224
|
+
* @returns {{content: string, removed: boolean}}
|
|
225
|
+
*/
|
|
226
|
+
export function stripUdsHookBlock(content) {
|
|
227
|
+
const lines = content.split('\n');
|
|
228
|
+
const out = [];
|
|
229
|
+
let removed = false;
|
|
230
|
+
for (let i = 0; i < lines.length; i++) {
|
|
231
|
+
if (lines[i].trim() === UDS_HOOK_MARKER) {
|
|
232
|
+
let end = -1;
|
|
233
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
234
|
+
if (lines[j].trim() === UDS_HOOK_END_MARKER) { end = j; break; }
|
|
235
|
+
// Another marker before an end marker means this block is not well-formed.
|
|
236
|
+
if (lines[j].trim() === UDS_HOOK_MARKER) break;
|
|
237
|
+
}
|
|
238
|
+
if (end !== -1) {
|
|
239
|
+
i = end;
|
|
240
|
+
removed = true;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
out.push(lines[i]);
|
|
245
|
+
}
|
|
246
|
+
return { content: out.join('\n'), removed };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Does `filePath` contain a hook line UDS manages (the old one-liner or the current block)? */
|
|
130
250
|
function hasUdsMarker(filePath) {
|
|
131
251
|
try {
|
|
132
|
-
return readFileSync(filePath, 'utf-8')
|
|
252
|
+
return hookRunsUdsCheck(readFileSync(filePath, 'utf-8'));
|
|
133
253
|
} catch {
|
|
134
254
|
return false;
|
|
135
255
|
}
|
|
@@ -225,7 +345,7 @@ export function stripLegacyHuskyShLine(content) {
|
|
|
225
345
|
* @param {string} projectPath
|
|
226
346
|
* @returns {{relevant: boolean, wired?: boolean, hookFile?: string,
|
|
227
347
|
* configuredHooksPath?: string|null, effectiveHooksDir?: string|null,
|
|
228
|
-
* legacyV8?: boolean, missingShebang?: boolean}}
|
|
348
|
+
* legacyV8?: boolean, missingShebang?: boolean, legacyBareUds?: boolean}}
|
|
229
349
|
* `relevant: false` means there is nothing UDS-managed to report on (no
|
|
230
350
|
* hook file, or hooks-dir could not be determined — never guess "unwired"
|
|
231
351
|
* from a failed lookup).
|
|
@@ -252,6 +372,13 @@ export function checkPreCommitHookWiring(projectPath) {
|
|
|
252
372
|
try { return !hasShebang(readFileSync(managedPath, 'utf-8')); } catch { return false; }
|
|
253
373
|
})();
|
|
254
374
|
|
|
375
|
+
// Independent of wiring, like missingShebang: the file can be wired and still ask
|
|
376
|
+
// npm for the bare name `uds` (see buildPreCommitBlock). Only the husky file is
|
|
377
|
+
// examined — that is the one UDS wrote the old one-liner into.
|
|
378
|
+
const legacyBareUds = hasHuskyHook && (() => {
|
|
379
|
+
try { return hasBareUdsRunner(readFileSync(huskyHookPath, 'utf-8')); } catch { return false; }
|
|
380
|
+
})();
|
|
381
|
+
|
|
255
382
|
const effectiveFile = join(effectiveHooksDir, 'pre-commit');
|
|
256
383
|
let wired = false;
|
|
257
384
|
if (existsSync(effectiveFile)) {
|
|
@@ -264,7 +391,7 @@ export function checkPreCommitHookWiring(projectPath) {
|
|
|
264
391
|
}
|
|
265
392
|
}
|
|
266
393
|
|
|
267
|
-
if (wired) return { relevant: true, wired: true, hookFile, missingShebang };
|
|
394
|
+
if (wired) return { relevant: true, wired: true, hookFile, missingShebang, legacyBareUds };
|
|
268
395
|
|
|
269
396
|
const legacyV8 = hasHuskyHook && (() => {
|
|
270
397
|
try { return hasLegacyHuskyShLine(readFileSync(huskyHookPath, 'utf-8')); } catch { return false; }
|
|
@@ -277,6 +404,7 @@ export function checkPreCommitHookWiring(projectPath) {
|
|
|
277
404
|
configuredHooksPath: getLocalHooksPathConfig(projectPath),
|
|
278
405
|
effectiveHooksDir,
|
|
279
406
|
legacyV8,
|
|
280
|
-
missingShebang
|
|
407
|
+
missingShebang,
|
|
408
|
+
legacyBareUds
|
|
281
409
|
};
|
|
282
410
|
}
|