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
|
@@ -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
|
@@ -47,6 +47,8 @@ import {
|
|
|
47
47
|
import { getMarketplaceSkillsInfo } from '../utils/github.js';
|
|
48
48
|
import { detectAITools } from '../utils/detector.js';
|
|
49
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';
|
|
50
52
|
import {
|
|
51
53
|
promptSkillsInstallLocation,
|
|
52
54
|
promptCommandsInstallation
|
|
@@ -498,6 +500,13 @@ export async function updateCommand(options) {
|
|
|
498
500
|
const scopedToSkills = !!options.skills;
|
|
499
501
|
const scopedToCommands = !!options.commands;
|
|
500
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
|
+
|
|
501
510
|
// Handle --rollback option (DSR). It restores a whole backup, so a scope
|
|
502
511
|
// flag cannot narrow it — say so rather than appearing to honour it.
|
|
503
512
|
if (options.rollback) {
|
|
@@ -1837,6 +1846,47 @@ async function switchClaudeTarget(projectPath, manifest, target, options) { // e
|
|
|
1837
1846
|
* @param {Object} manifest - Manifest object (will be mutated with updated hashes)
|
|
1838
1847
|
* @returns {{success: boolean, updated: string[], errors: string[]}}
|
|
1839
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
|
+
|
|
1840
1890
|
/**
|
|
1841
1891
|
* `uds update --with-hooks [--ai-tool <list>] [--plan] [--force]`
|
|
1842
1892
|
*
|
|
@@ -1858,6 +1908,9 @@ export async function updateHooksOnly(projectPath, manifest, options = {}) {
|
|
|
1858
1908
|
console.log();
|
|
1859
1909
|
}
|
|
1860
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
|
+
|
|
1861
1914
|
const capable = HOOK_CAPABLE_TOOLS.join(', ');
|
|
1862
1915
|
const resolved = resolveHookTools(projectPath, manifest, { aiTool: options.aiTool });
|
|
1863
1916
|
|
|
@@ -1887,11 +1940,16 @@ export async function updateHooksOnly(projectPath, manifest, options = {}) {
|
|
|
1887
1940
|
console.log(chalk.gray(` ${tool}: ${resolved.sources[tool].join(', ')}`));
|
|
1888
1941
|
}
|
|
1889
1942
|
|
|
1890
|
-
const { results, scripts } = installMissingHooks(projectPath, resolved.tools, {
|
|
1943
|
+
const { results, scripts, artifacts } = installMissingHooks(projectPath, resolved.tools, {
|
|
1891
1944
|
plan: !!options.plan,
|
|
1892
1945
|
overwriteScripts: !!options.force,
|
|
1893
1946
|
});
|
|
1894
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
|
+
|
|
1895
1953
|
let failed = false;
|
|
1896
1954
|
for (const r of results) {
|
|
1897
1955
|
const rel = r.path ? relative(projectPath, r.path) || r.path : '';
|
|
@@ -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
|
+
|