universal-dev-standards 6.14.0-beta.1 → 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/bin/uds.js +37 -0
- package/bundled/ai/standards/open-work-tracking.ai.yaml +7 -2
- package/bundled/core/open-work-tracking.md +1 -1
- package/bundled/hooks/turn-completion/locales/en.mjs +54 -5
- package/bundled/hooks/turn-completion/locales/zh-TW.mjs +37 -6
- package/bundled/locales/zh-CN/CHANGELOG.md +38 -3
- package/bundled/locales/zh-CN/README.md +2 -2
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +2 -0
- package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +24 -0
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +11 -4
- package/bundled/locales/zh-TW/CHANGELOG.md +38 -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/open-work-tracking.md +4 -4
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +2 -0
- package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +24 -0
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +11 -4
- package/package.json +1 -1
- package/src/commands/check.js +9 -0
- package/src/commands/init.js +73 -18
- package/src/commands/open-work.js +60 -0
- package/src/commands/uninstall.js +144 -30
- package/src/commands/update.js +149 -0
- package/src/core/install-records.js +191 -0
- package/src/i18n/messages.js +42 -6
- package/src/installers/hooks-installer.js +236 -27
- 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/detector.js +46 -1
- 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 +794 -0
- package/standards-registry.json +7 -7
|
@@ -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
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -46,6 +46,9 @@ import {
|
|
|
46
46
|
} from '../config/ai-agent-paths.js';
|
|
47
47
|
import { getMarketplaceSkillsInfo } from '../utils/github.js';
|
|
48
48
|
import { detectAITools } from '../utils/detector.js';
|
|
49
|
+
import { HOOK_CAPABLE_TOOLS, resolveHookTools, installMissingHooks } from '../installers/hooks-installer.js';
|
|
50
|
+
import { persistRecorder, mergeRecorderInto } from '../core/install-records.js';
|
|
51
|
+
import { migrateLegacyHuskyHook } from '../utils/legacy-hook-migration.js';
|
|
49
52
|
import {
|
|
50
53
|
promptSkillsInstallLocation,
|
|
51
54
|
promptCommandsInstallation
|
|
@@ -451,6 +454,15 @@ export async function updateCommand(options) {
|
|
|
451
454
|
return;
|
|
452
455
|
}
|
|
453
456
|
|
|
457
|
+
// Handle --with-hooks (added 2026-09-29): install the enforcement hooks that are
|
|
458
|
+
// MISSING from an already-initialized project — the door `uds init --with-hooks`
|
|
459
|
+
// cannot be, because `uds init` refuses to run twice. Standalone like
|
|
460
|
+
// --claude-target and --sync-refs; and like them it honours --plan by writing nothing.
|
|
461
|
+
if (options.withHooks) {
|
|
462
|
+
await updateHooksOnly(projectPath, manifest, options);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
454
466
|
// Handle --sync-refs option.
|
|
455
467
|
// `--plan` is honoured here too. This branch is above the mode dispatch
|
|
456
468
|
// because sync-refs is its own operation rather than a scope of the
|
|
@@ -488,6 +500,13 @@ export async function updateCommand(options) {
|
|
|
488
500
|
const scopedToSkills = !!options.skills;
|
|
489
501
|
const scopedToCommands = !!options.commands;
|
|
490
502
|
|
|
503
|
+
// A pre-commit line an older UDS wrote asks npm for the bare name `uds`, which
|
|
504
|
+
// is not this project. Fixed here, before any mode below can return early
|
|
505
|
+
// (an up-to-date adopter still has it). Narrowed runs stay narrowed.
|
|
506
|
+
if (!options.rollback && !scopedToSkills && !scopedToCommands && !options.standardsOnly) {
|
|
507
|
+
migrateLegacyPreCommitHook(projectPath, manifest, { plan: !!options.plan });
|
|
508
|
+
}
|
|
509
|
+
|
|
491
510
|
// Handle --rollback option (DSR). It restores a whole backup, so a scope
|
|
492
511
|
// flag cannot narrow it — say so rather than appearing to honour it.
|
|
493
512
|
if (options.rollback) {
|
|
@@ -1827,6 +1846,136 @@ async function switchClaudeTarget(projectPath, manifest, target, options) { // e
|
|
|
1827
1846
|
* @param {Object} manifest - Manifest object (will be mutated with updated hashes)
|
|
1828
1847
|
* @returns {{success: boolean, updated: string[], errors: string[]}}
|
|
1829
1848
|
*/
|
|
1849
|
+
/**
|
|
1850
|
+
* Swap the pre-commit line an older UDS wrote (`npx` + the bare name `uds`) for
|
|
1851
|
+
* the block current UDS writes, and say what was done or why not.
|
|
1852
|
+
*
|
|
1853
|
+
* Runs in every `uds update` mode that is not narrowed to something else, and in
|
|
1854
|
+
* `--with-hooks`, and it runs BEFORE the "already up to date" early return: an
|
|
1855
|
+
* adopter whose standards are current is exactly the one still carrying the old
|
|
1856
|
+
* line. Under `--plan` it reports and writes nothing.
|
|
1857
|
+
*
|
|
1858
|
+
* Only a line UDS can be shown to have written is changed (exact match under
|
|
1859
|
+
* UDS's marker comment — see legacy-hook-migration.js); any other bare-name line
|
|
1860
|
+
* is reported with its line number and left alone.
|
|
1861
|
+
*
|
|
1862
|
+
* @param {string} projectPath
|
|
1863
|
+
* @param {object} manifest - mutated only to carry a refreshed install record
|
|
1864
|
+
* @param {{plan?: boolean}} [opts]
|
|
1865
|
+
* @returns {ReturnType<typeof migrateLegacyHuskyHook>}
|
|
1866
|
+
*/
|
|
1867
|
+
export function migrateLegacyPreCommitHook(projectPath, manifest, { plan = false } = {}) {
|
|
1868
|
+
const msg = t().commands.update;
|
|
1869
|
+
const r = migrateLegacyHuskyHook(projectPath, { plan, manifest });
|
|
1870
|
+
if (r.state === 'error') {
|
|
1871
|
+
console.log(chalk.yellow(` ${msg.hookMigrateFailed.replace('{error}', r.error)}`));
|
|
1872
|
+
console.log();
|
|
1873
|
+
return r;
|
|
1874
|
+
}
|
|
1875
|
+
if (r.state === 'migrated') console.log(chalk.green(` ${msg.hookMigrated}`));
|
|
1876
|
+
if (r.state === 'would-migrate') console.log(chalk.cyan(` ${msg.hookWouldMigrate}`));
|
|
1877
|
+
for (const k of r.kept) {
|
|
1878
|
+
console.log(chalk.yellow(` ${msg.hookLegacyKept.replace('{line}', k.line).replace('{text}', k.text)}`));
|
|
1879
|
+
}
|
|
1880
|
+
if (r.state !== 'none') console.log();
|
|
1881
|
+
if (r.recorder) {
|
|
1882
|
+
persistRecorder(projectPath, r.recorder);
|
|
1883
|
+
// The manifest object is written again later in most update paths; without
|
|
1884
|
+
// this the record persisted just above would be overwritten by the older copy.
|
|
1885
|
+
Object.assign(manifest, mergeRecorderInto(manifest, r.recorder));
|
|
1886
|
+
}
|
|
1887
|
+
return r;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* `uds update --with-hooks [--ai-tool <list>] [--plan] [--force]`
|
|
1892
|
+
*
|
|
1893
|
+
* Adds the enforcement hooks that are missing; a hook that is already there is not
|
|
1894
|
+
* touched, and the adopter's own hooks are never touched (see installMissingHooks).
|
|
1895
|
+
* Exit code 1 only when there is no hook-capable tool to act on, or an install failed:
|
|
1896
|
+
* "nothing to do" is 0, "could not tell which tool" is not.
|
|
1897
|
+
*
|
|
1898
|
+
* @param {string} projectPath
|
|
1899
|
+
* @param {object} manifest
|
|
1900
|
+
* @param {{ plan?: boolean, force?: boolean, aiTool?: string, yes?: boolean }} options
|
|
1901
|
+
* @returns {Promise<void>}
|
|
1902
|
+
*/
|
|
1903
|
+
export async function updateHooksOnly(projectPath, manifest, options = {}) {
|
|
1904
|
+
const ignored = ['skills', 'commands', 'syncRefs', 'integrationsOnly', 'standardsOnly', 'apply', 'rollback', 'prune']
|
|
1905
|
+
.filter((k) => options[k]).map((k) => `--${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}`);
|
|
1906
|
+
if (ignored.length) {
|
|
1907
|
+
console.log(chalk.yellow(` ! --with-hooks does one thing and does not compose; ${ignored.join(', ')} ignored.`));
|
|
1908
|
+
console.log();
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// The hook lines UDS wrote in the past belong to this command's subject too.
|
|
1912
|
+
migrateLegacyPreCommitHook(projectPath, manifest, { plan: !!options.plan });
|
|
1913
|
+
|
|
1914
|
+
const capable = HOOK_CAPABLE_TOOLS.join(', ');
|
|
1915
|
+
const resolved = resolveHookTools(projectPath, manifest, { aiTool: options.aiTool });
|
|
1916
|
+
|
|
1917
|
+
if (resolved.unknown.length) {
|
|
1918
|
+
console.log(chalk.red(` ✗ No hook installer for: ${resolved.unknown.join(', ')}`));
|
|
1919
|
+
console.log(chalk.gray(` Tools with hooks: ${capable}`));
|
|
1920
|
+
console.log();
|
|
1921
|
+
process.exitCode = 1;
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
if (resolved.tools.length === 0) {
|
|
1925
|
+
// Both non-interactive and interactive get the same message: there is no prompt to
|
|
1926
|
+
// fall back to here, so the way to say which tool is spelled out instead.
|
|
1927
|
+
console.log(chalk.yellow(' ⚠ Could not tell which AI tool to install hooks for.'));
|
|
1928
|
+
console.log(chalk.gray(' Neither this project\'s manifest nor its files name one of: ' + capable + '.'));
|
|
1929
|
+
console.log(chalk.gray(' Say which one: uds update --with-hooks --ai-tool <tool>[,<tool>...]'));
|
|
1930
|
+
console.log(chalk.gray(' e.g. uds update --with-hooks --ai-tool antigravity'));
|
|
1931
|
+
console.log(chalk.gray(' What is detected: claude-code (.claude/ or CLAUDE.md) · codex (root AGENTS.md) · gemini-cli (GEMINI.md)'));
|
|
1932
|
+
console.log(chalk.gray(' antigravity (.agents/AGENTS.md, .agents/rules/, .agents/workflows/, .agents/plugins/ or .agents/hooks.json; .agents/skills/ is shared with Codex and does not count)'));
|
|
1933
|
+
console.log();
|
|
1934
|
+
process.exitCode = 1;
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
console.log(chalk.bold(options.plan ? 'Hooks — plan (nothing is written)' : 'Hooks'));
|
|
1939
|
+
for (const tool of resolved.tools) {
|
|
1940
|
+
console.log(chalk.gray(` ${tool}: ${resolved.sources[tool].join(', ')}`));
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
const { results, scripts, artifacts } = installMissingHooks(projectPath, resolved.tools, {
|
|
1944
|
+
plan: !!options.plan,
|
|
1945
|
+
overwriteScripts: !!options.force,
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
// Record what was just written (hook scripts, and folders UDS had to create) in
|
|
1949
|
+
// the manifest, so `uds uninstall` can remove exactly that. Never on --plan: a
|
|
1950
|
+
// plan writes nothing, and the recorder is empty then anyway.
|
|
1951
|
+
if (!options.plan) persistRecorder(projectPath, artifacts);
|
|
1952
|
+
|
|
1953
|
+
let failed = false;
|
|
1954
|
+
for (const r of results) {
|
|
1955
|
+
const rel = r.path ? relative(projectPath, r.path) || r.path : '';
|
|
1956
|
+
if (r.outcome === 'installed') console.log(chalk.green(` ✓ ${r.tool}: installed${r.repaired ? ' (replaced an out-of-date UDS entry)' : ''} — ${rel}`));
|
|
1957
|
+
else if (r.outcome === 'would-install') console.log(chalk.cyan(` + ${r.tool}: would install${r.repaired ? ' (replacing an out-of-date UDS entry)' : ''} — ${rel}`));
|
|
1958
|
+
else if (r.outcome === 'unchanged') console.log(chalk.gray(` · ${r.tool}: already installed, not touched — ${rel}`));
|
|
1959
|
+
else { failed = true; console.log(chalk.yellow(` ⚠ ${r.tool}: not installed — ${r.why}`)); }
|
|
1960
|
+
}
|
|
1961
|
+
const installedAny = results.some((r) => r.outcome === 'installed' || r.outcome === 'would-install');
|
|
1962
|
+
if (installedAny && scripts.kept.length) {
|
|
1963
|
+
console.log(chalk.yellow(` ! ${scripts.kept.length} hook script(s) in scripts/hooks/ differ from this UDS version and were kept: ${scripts.kept.slice(0, 5).join(', ')}${scripts.kept.length > 5 ? ', ...' : ''}`));
|
|
1964
|
+
console.log(chalk.gray(' Add --force to overwrite them with the shipped versions.'));
|
|
1965
|
+
}
|
|
1966
|
+
if (results.some((r) => r.tool === 'antigravity' && r.outcome === 'installed')) {
|
|
1967
|
+
console.log(chalk.yellow(' ⚠ Verified against a real agy session for a single turn without tool calls in `agy -p` mode only; multi-turn, tool-call turns and interactive mode are not yet verified.'));
|
|
1968
|
+
}
|
|
1969
|
+
if (results.some((r) => r.tool === 'codex' && r.outcome === 'installed')) {
|
|
1970
|
+
console.log(chalk.yellow(' ⚠ Codex will not run it until you trust it: open Codex in this project, trust the project, then run /hooks and trust this hook.'));
|
|
1971
|
+
}
|
|
1972
|
+
if (!results.some((r) => r.outcome === 'installed' || r.outcome === 'would-install')) {
|
|
1973
|
+
console.log(chalk.gray(' Nothing to add.'));
|
|
1974
|
+
}
|
|
1975
|
+
console.log();
|
|
1976
|
+
if (failed) process.exitCode = 1;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1830
1979
|
export function regenerateIntegrations(projectPath, manifest) {
|
|
1831
1980
|
const aiTools = manifest.aiTools || [];
|
|
1832
1981
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install records — the manifest's account of what UDS itself put on disk, kept
|
|
3
|
+
* so that `uds uninstall` removes exactly that and nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Why a record and not a rule: `uds uninstall` used to leave four kinds of UDS
|
|
6
|
+
* output behind (hook scripts under scripts/hooks/, emptied `.codex/`-style
|
|
7
|
+
* folders, integration files whose generated header sits outside the UDS
|
|
8
|
+
* marker block, and the body of the native pre-commit hook). Each could be
|
|
9
|
+
* cleaned by pattern-matching on its content or path — and each of those
|
|
10
|
+
* patterns also matches files an adopter wrote themselves (`scripts/hooks/` is
|
|
11
|
+
* a directory UDS scaffolds INSIDE the adopter's project, where their own hook
|
|
12
|
+
* scripts may live). Origin is a fact about the moment of writing, so it is
|
|
13
|
+
* recorded then: a file is deleted only if a record says UDS wrote it AND its
|
|
14
|
+
* content still hashes to what UDS wrote. No record, or a changed hash, means
|
|
15
|
+
* the file is kept and the reason is printed.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NOT stored in `fileHashes` (`uds check` treats that as the list of
|
|
18
|
+
* standards files and would report these as modified/untracked) or in
|
|
19
|
+
* `provenance` (`uds update` prunes entries outside its expected set).
|
|
20
|
+
* `installedArtifacts` is read by nothing except uninstall.
|
|
21
|
+
*
|
|
22
|
+
* Shape:
|
|
23
|
+
* installedArtifacts: {
|
|
24
|
+
* files: { '<rel/path>': { kind, hash, size, installedAt } },
|
|
25
|
+
* createdDirs: ['<rel/dir>', ...] // directories UDS had to mkdir
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* `kind` selects what `hash` covers:
|
|
29
|
+
* 'hook-script', 'git-hook' → whole file (line endings normalized)
|
|
30
|
+
* 'integration-file' → everything outside the UDS marker block
|
|
31
|
+
*
|
|
32
|
+
* @module core/install-records
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { existsSync, mkdirSync, lstatSync, readFileSync, writeFileSync } from 'fs';
|
|
36
|
+
import { join, dirname, relative, isAbsolute } from 'path';
|
|
37
|
+
import { computeFileHash, computeOutsideBlockHash } from '../utils/hasher.js';
|
|
38
|
+
import { getManifestPath } from './manifest.js';
|
|
39
|
+
|
|
40
|
+
export const RECORDS_KEY = 'installedArtifacts';
|
|
41
|
+
|
|
42
|
+
export const RECORD_KINDS = Object.freeze({
|
|
43
|
+
HOOK_SCRIPT: 'hook-script',
|
|
44
|
+
GIT_HOOK: 'git-hook',
|
|
45
|
+
INTEGRATION_FILE: 'integration-file'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const norm = (p) => String(p).replace(/\\/g, '/');
|
|
49
|
+
|
|
50
|
+
/** A fresh, empty recorder. Installers fill it; a caller persists it. */
|
|
51
|
+
export function newRecorder() {
|
|
52
|
+
return { files: {}, createdDirs: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The hash a record of this kind holds for a file as it is on disk right now.
|
|
57
|
+
* Also what uninstall recomputes and compares — one function, so the two sides
|
|
58
|
+
* cannot drift apart.
|
|
59
|
+
* @returns {{hash: string, size: number}|null}
|
|
60
|
+
*/
|
|
61
|
+
export function currentHashFor(kind, absPath) {
|
|
62
|
+
if (kind === RECORD_KINDS.INTEGRATION_FILE) return computeOutsideBlockHash(absPath);
|
|
63
|
+
return computeFileHash(absPath);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Record a file UDS has just written. Call it AFTER the write, so the hash is of
|
|
68
|
+
* what is on disk. No-op (returns false) if the file cannot be hashed.
|
|
69
|
+
*/
|
|
70
|
+
export function recordFile(recorder, projectPath, relPath, kind) {
|
|
71
|
+
const rel = norm(relPath);
|
|
72
|
+
const h = currentHashFor(kind, join(projectPath, rel));
|
|
73
|
+
if (!h) return false;
|
|
74
|
+
recorder.files[rel] = { kind, hash: h.hash, size: h.size, installedAt: new Date().toISOString() };
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `mkdir -p`, remembering every directory that did not exist before this call.
|
|
80
|
+
* "UDS created this directory" is only knowable at the moment of creation; after
|
|
81
|
+
* that an empty `.codex/` looks the same whether UDS or the adopter made it.
|
|
82
|
+
*/
|
|
83
|
+
export function mkdirTracked(recorder, projectPath, absDir) {
|
|
84
|
+
const missing = [];
|
|
85
|
+
let cur = absDir;
|
|
86
|
+
while (!existsSync(cur)) {
|
|
87
|
+
missing.push(cur);
|
|
88
|
+
const parent = dirname(cur);
|
|
89
|
+
if (parent === cur) break;
|
|
90
|
+
cur = parent;
|
|
91
|
+
}
|
|
92
|
+
if (missing.length === 0) return;
|
|
93
|
+
mkdirSync(absDir, { recursive: true });
|
|
94
|
+
for (const dir of missing.reverse()) {
|
|
95
|
+
const rel = norm(relative(projectPath, dir));
|
|
96
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
|
|
97
|
+
if (!recorder.createdDirs.includes(rel)) recorder.createdDirs.push(rel);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fold a recorder into a manifest object (returned as a new object; input untouched). */
|
|
102
|
+
export function mergeRecorderInto(manifest, recorder) {
|
|
103
|
+
const prev = manifest?.[RECORDS_KEY] || {};
|
|
104
|
+
const dirs = new Set([...(prev.createdDirs || []), ...(recorder?.createdDirs || [])]);
|
|
105
|
+
return {
|
|
106
|
+
...manifest,
|
|
107
|
+
[RECORDS_KEY]: {
|
|
108
|
+
files: { ...(prev.files || {}), ...(recorder?.files || {}) },
|
|
109
|
+
createdDirs: [...dirs]
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Drop records for paths UDS has now removed. */
|
|
115
|
+
export function forgetRecords(manifest, relPaths) {
|
|
116
|
+
const prev = manifest?.[RECORDS_KEY];
|
|
117
|
+
if (!prev) return manifest;
|
|
118
|
+
const gone = new Set((relPaths || []).map(norm));
|
|
119
|
+
const files = Object.fromEntries(Object.entries(prev.files || {}).filter(([k]) => !gone.has(k)));
|
|
120
|
+
const createdDirs = (prev.createdDirs || []).filter((d) => !gone.has(d));
|
|
121
|
+
return { ...manifest, [RECORDS_KEY]: { files, createdDirs } };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The record for a path, or undefined. */
|
|
125
|
+
export function getFileRecord(manifest, relPath) {
|
|
126
|
+
return manifest?.[RECORDS_KEY]?.files?.[norm(relPath)];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** True when the recorder holds anything worth writing. */
|
|
130
|
+
export function hasRecords(recorder) {
|
|
131
|
+
return !!recorder && (Object.keys(recorder.files).length > 0 || recorder.createdDirs.length > 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Persist a recorder into the project's manifest on disk. For callers that run
|
|
136
|
+
* after the manifest was written (init's hook step, `uds update --with-hooks`).
|
|
137
|
+
* Returns false — and writes nothing — when there is nothing to record or no
|
|
138
|
+
* manifest to record it in.
|
|
139
|
+
*
|
|
140
|
+
* Reads and writes the manifest as RAW JSON, not through `readManifest`. That
|
|
141
|
+
* function migrates on read (it rewrites `standards` from paths to registry IDs,
|
|
142
|
+
* among other normalizations), so a read-modify-write through it would silently
|
|
143
|
+
* change every other field in the file as a side effect of recording one new
|
|
144
|
+
* one — measured: the e2e test "auto-restore missing files" reads `standards`
|
|
145
|
+
* straight from the file `uds init` wrote and started failing because init's
|
|
146
|
+
* manifest had come out in a different format.
|
|
147
|
+
*/
|
|
148
|
+
export function persistRecorder(projectPath, recorder) {
|
|
149
|
+
if (!hasRecords(recorder)) return false;
|
|
150
|
+
const manifestPath = getManifestPath(projectPath);
|
|
151
|
+
if (!existsSync(manifestPath)) return false;
|
|
152
|
+
let raw;
|
|
153
|
+
try {
|
|
154
|
+
raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (!raw || typeof raw !== 'object') return false;
|
|
159
|
+
writeFileSync(manifestPath, JSON.stringify(mergeRecorderInto(raw, recorder), null, 2));
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Is `absPath` a real directory (not a symlink to one)? Uninstall never follows links. */
|
|
164
|
+
export function isRealDirectory(absPath) {
|
|
165
|
+
try {
|
|
166
|
+
const st = lstatSync(absPath);
|
|
167
|
+
return st.isDirectory() && !st.isSymbolicLink();
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @returns {{ state: 'proven'|'no-record'|'changed', why: string }}
|
|
175
|
+
* proven a record exists and the file still hashes to it
|
|
176
|
+
* no-record nothing says UDS wrote it (older UDS, or written by hand)
|
|
177
|
+
* changed UDS wrote it, and it has been edited since
|
|
178
|
+
*/
|
|
179
|
+
export function proveUnchanged(manifest, projectPath, relPath) {
|
|
180
|
+
const rec = getFileRecord(manifest, relPath);
|
|
181
|
+
if (!rec) {
|
|
182
|
+
return {
|
|
183
|
+
state: 'no-record',
|
|
184
|
+
why: 'no install record — installed by an older UDS or not by UDS, so UDS cannot prove it wrote this'
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const now = currentHashFor(rec.kind, join(projectPath, relPath));
|
|
188
|
+
if (now && now.hash === rec.hash) return { state: 'proven', why: 'unchanged since UDS wrote it' };
|
|
189
|
+
return { state: 'changed', why: 'modified since UDS wrote it' };
|
|
190
|
+
}
|
|
191
|
+
|