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.
@@ -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
- const categories = await checkbox({
57
- message: msg.selectCategories,
58
- choices: [
59
- { name: `${msg.categoryHooks} (.husky/pre-commit, .claude/settings.json, .codex/hooks.json, .gemini/settings.json, .agents/hooks.json)`, value: 'hooks', checked: true },
60
- { name: `${msg.categorySkills} (skills, commands)`, value: 'skills', checked: true },
61
- { name: `${msg.categoryIntegrations} (CLAUDE.md, .cursorrules, ...)`, value: 'integrations', checked: true },
62
- { name: `${msg.categoryStandards} (.standards/)`, value: 'standards', checked: true }
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
- const confirmed = await confirm({
91
- message: msg.confirmUninstall,
92
- default: false
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
- const results = await executeUninstall(
110
- projectPath, manifest, selectedCategories,
111
- { includeUserLevel, interactive: !options.yes }
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
- const updated = { ...manifest };
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
  }
@@ -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
+