@ngockhoale/ukit 1.6.5 → 1.6.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 1.6.6 - 2026-08-03
6
+
7
+ ### Fixed
8
+
9
+ - **`ensureGitignore`** no longer silently no-ops on a damaged UKit block (missing/duplicate end marker). Marker detection is now a single source of truth (`parseGitignore` + `locateBlock`, exact-line matching, shared with `removeGitignoreBlock`), fixing data loss on substring-collision lines, content between two legitimate blocks, and CRLF round-tripping, while staying idempotent.
10
+ - **Install summary counts** no longer go negative or misattribute a skip. `applyDiffResults` now tracks skips per planned action (`create`/`update`), and `runInstallPipeline` subtracts each skip from its own bucket instead of always assuming `update`.
11
+ - **Runtime config default version** is now read from `package.json` instead of a hardcoded, drifting literal (was stuck at `1.5.2`).
12
+ - **`mergeObjects` (runtime config)** now refuses `__proto__`, `constructor`, and `prototype` keys at every merge depth, closing a prototype-pollution path via a hand-edited `.ukit/storage/config.json`.
13
+ - **`buildInstallPlan`** no longer aborts the entire install when a template is missing for a *non-selected/excluded* item — it skips quietly. A missing template for a *selected* item still fails loudly with a clear error.
14
+ - **`uninstallUkit`** now reports out-of-scope/forged tracked paths on real runs, not only `--dry-run`, while routine link-child paths stay silent.
15
+
5
16
  ## 1.6.5 - 2026-08-02
6
17
 
7
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "1.6.5",
3
+ "version": "1.6.6",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, Antigravity, OpenAI Codex, and OpenCode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,6 +15,7 @@ function isTccProtectedPath(filePath) {
15
15
  export async function applyDiffResults(diffResults, { backupRoot, projectRoot } = {}) {
16
16
  const writes = [];
17
17
  let skippedUpdates = 0;
18
+ const skippedByAction = { create: 0, update: 0 };
18
19
 
19
20
  for (const entry of diffResults) {
20
21
  if (entry.action !== 'create' && entry.action !== 'update') {
@@ -36,6 +37,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
36
37
  `[UKit] Warning: skipping link update for ${entry.targetPath} — exists as a ${existingKind} (possible user content). Remove it manually if intended.`,
37
38
  );
38
39
  skippedUpdates += 1;
40
+ skippedByAction[entry.action] += 1;
39
41
  continue;
40
42
  }
41
43
  }
@@ -47,6 +49,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
47
49
  `[UKit] Warning: skipping link creation for ${entry.targetPath} — permission denied. Run 'ukit install' from within the target tool to create it.`,
48
50
  );
49
51
  skippedUpdates += 1;
52
+ skippedByAction[entry.action] += 1;
50
53
  continue;
51
54
  }
52
55
  throw linkError;
@@ -78,6 +81,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
78
81
  `[UKit] Warning: skipping backup for ${entry.targetPath} — permission denied.`,
79
82
  );
80
83
  skippedUpdates += 1;
84
+ skippedByAction.update += 1;
81
85
  continue;
82
86
  }
83
87
  throw backupError;
@@ -111,6 +115,7 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
111
115
  `[UKit] Warning: skipping write for ${entry.targetPath} — permission denied (macOS TCC). Run 'ukit install' from within the owning tool to update it.`,
112
116
  );
113
117
  skippedUpdates += 1;
118
+ skippedByAction[entry.action] += 1;
114
119
  continue;
115
120
  }
116
121
  throw writeError;
@@ -126,5 +131,5 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
126
131
  });
127
132
  }
128
133
 
129
- return { writes, skippedUpdates };
134
+ return { writes, skippedUpdates, skippedByAction };
130
135
  }
@@ -101,7 +101,17 @@ export async function buildInstallPlan({
101
101
  for (const item of manifest.items) {
102
102
  if (!selectedIds.has(item.id) && item.type !== 'link') {
103
103
  const sourcePath = safeResolve(templatesRoot, item.sourceTemplate);
104
- const stat = await fs.stat(sourcePath);
104
+ let stat;
105
+ try {
106
+ stat = await fs.stat(sourcePath);
107
+ } catch (error) {
108
+ if (error?.code === 'ENOENT') {
109
+ // An excluded item only contributes an auto-discovery filter, so a
110
+ // missing source for a pack the user does not have is harmless.
111
+ continue;
112
+ }
113
+ throw error;
114
+ }
105
115
  const sourceDir = (stat.isDirectory() ? sourcePath : path.dirname(sourcePath)) + path.sep;
106
116
  excludedSourceDirs.push(sourceDir);
107
117
  }
@@ -145,7 +155,11 @@ export async function buildInstallPlan({
145
155
  });
146
156
  continue;
147
157
  }
148
- throw error;
158
+ throw new Error(
159
+ `Cannot install manifest item "${item.id}": missing or unreadable template `
160
+ + `"${item.sourceTemplate}".`,
161
+ { cause: error },
162
+ );
149
163
  }
150
164
  if (stat.isDirectory()) {
151
165
  const nestedFiles = await walkDirectory(sourcePath);
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { escapeRegExp, writeFileAtomic } from './fileOps.js';
3
+ import { writeFileAtomic } from './fileOps.js';
4
4
 
5
5
  const UKIT_ENTRIES = [
6
6
  '.cache/',
@@ -20,6 +20,76 @@ const UKIT_ENTRIES = [
20
20
  const MARKER_START = '# UKit generated (do not edit)';
21
21
  const MARKER_END = '# /UKit';
22
22
 
23
+ /*
24
+ * Single source of truth for "where is the UKit block in this file".
25
+ *
26
+ * Both `ensureGitignore` and `removeGitignoreBlock` go through `parseGitignore`
27
+ * + `locateBlock`. There is deliberately NO regex/substring notion of a marker
28
+ * anywhere else in this module — historically the fast path used a substring
29
+ * regex, the repair path used `line === MARKER`, and the entry guard used
30
+ * `content.includes(...)`; those three disagreed and silently deleted user
31
+ * lines that merely *contained* marker-like text.
32
+ *
33
+ * Documented behaviour:
34
+ * - A marker is ONLY a line that, after stripping a trailing `\r`, is exactly
35
+ * equal to MARKER_START / MARKER_END. `# UKit generated (do not edit) v2`
36
+ * or a marker with a trailing space is ordinary user content, everywhere.
37
+ * - Line endings: the file's *dominant* ending wins (CRLF only if CRLF pairs
38
+ * outnumber bare LFs; ties go to LF). A file is only ever rewritten when its
39
+ * content actually changes, and a rewrite normalises the whole file to that
40
+ * dominant ending — so a mixed-ending file converges after one normalising
41
+ * write.
42
+ * - Only the FIRST block is managed. Any further marker lines are treated as
43
+ * user content so a second, legitimate block — and every user line between
44
+ * the two — is never touched.
45
+ */
46
+
47
+ /**
48
+ * Detect the dominant line ending and split into `\r`-free lines.
49
+ * @param {string} content
50
+ * @returns {{ eol: string, lines: string[] }}
51
+ */
52
+ function parseGitignore(content) {
53
+ const crlfCount = (content.match(/\r\n/g) ?? []).length;
54
+ const lfCount = (content.match(/\n/g) ?? []).length;
55
+ const bareLfCount = lfCount - crlfCount;
56
+ const eol = crlfCount > bareLfCount ? '\r\n' : '\n';
57
+ const lines = content
58
+ .split('\n')
59
+ .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
60
+ return { eol, lines };
61
+ }
62
+
63
+ /**
64
+ * Locate the first UKit block at or after `from`, using exact-line equality only.
65
+ *
66
+ * The block spans `[startIdx, endIdx]` inclusive. The search for the closing
67
+ * marker is bounded by the NEXT start marker, so an excision can never run past
68
+ * a second block. If no end marker exists inside that bound the block is
69
+ * "unterminated" and ends just before the next start marker (or at EOF).
70
+ *
71
+ * @param {string[]} lines `\r`-free lines from {@link parseGitignore}
72
+ * @param {number} [from]
73
+ * @returns {{ startIdx: number, endIdx: number, terminated: boolean } | null}
74
+ */
75
+ function locateBlock(lines, from = 0) {
76
+ const startIdx = lines.indexOf(MARKER_START, from);
77
+ if (startIdx === -1) return null;
78
+
79
+ const next = lines.indexOf(MARKER_START, startIdx + 1);
80
+ const bound = next === -1 ? lines.length : next;
81
+
82
+ // Last end marker *within the bound* — consumes stray/duplicate end markers
83
+ // that belong to this damaged block without reaching into the next one.
84
+ let endIdx = -1;
85
+ for (let i = startIdx + 1; i < bound; i += 1) {
86
+ if (lines[i] === MARKER_END) endIdx = i;
87
+ }
88
+
89
+ const terminated = endIdx !== -1;
90
+ return { startIdx, endIdx: terminated ? endIdx : bound - 1, terminated };
91
+ }
92
+
23
93
  export async function ensureGitignore(projectRoot) {
24
94
  const gitignorePath = path.join(projectRoot, '.gitignore');
25
95
 
@@ -30,48 +100,60 @@ export async function ensureGitignore(projectRoot) {
30
100
  // no .gitignore yet
31
101
  }
32
102
 
33
- const newBlock = [
34
- MARKER_START,
35
- ...UKIT_ENTRIES,
36
- MARKER_END,
37
- ].join('\n');
38
-
39
- if (content.includes(MARKER_START)) {
40
- // Replace existing UKit block with current entries
41
- const blockPattern = new RegExp(
42
- `${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}`,
43
- );
44
- const existingBlock = content.match(blockPattern)?.[0];
45
- if (existingBlock === newBlock) {
46
- return false; // already up to date
103
+ const { eol, lines } = parseGitignore(content);
104
+ const blockLines = [MARKER_START, ...UKIT_ENTRIES, MARKER_END];
105
+ const block = locateBlock(lines);
106
+
107
+ let nextLines;
108
+ if (block) {
109
+ // Splice the canonical block over the located one, in place. Well-formed,
110
+ // unterminated and stray-end-marker blocks all take this one path — a
111
+ // well-formed, current block simply splices to identical content and is
112
+ // caught by the `next === content` no-op check below, which is what makes
113
+ // repeated calls converge.
114
+ nextLines = [
115
+ ...lines.slice(0, block.startIdx),
116
+ ...blockLines,
117
+ ...lines.slice(block.endIdx + 1),
118
+ ];
119
+ } else {
120
+ // No marker block — check if entries are already present as active
121
+ // (non-comment) lines. Must filter out comment lines to avoid
122
+ // false-negatives like "# placeholder .claude/ukit/.ukit/" matching the
123
+ // entry check and incorrectly skipping the block insertion.
124
+ const activeLines = lines
125
+ .map((l) => l.trim())
126
+ .filter((trimmed) => trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!'));
127
+ const missing = UKIT_ENTRIES.filter((e) => !activeLines.some((l) => l === e));
128
+ if (missing.length === 0) {
129
+ return false;
47
130
  }
48
- const updated = content.replace(blockPattern, newBlock);
49
- await writeFileAtomic(gitignorePath, updated);
50
- return true;
51
- }
52
131
 
53
- // No marker block — check if entries are already present as active (non-comment) lines.
54
- // Must filter out comment lines to avoid false-negatives like "# placeholder .claude/ukit/.ukit/"
55
- // matching the entry check and incorrectly skipping the block insertion.
56
- const activeLines = content
57
- .split('\n')
58
- .map((l) => l.trim())
59
- .filter((trimmed) => trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('!'));
60
- const missing = UKIT_ENTRIES.filter((e) => !activeLines.some((l) => l === e));
61
- if (missing.length === 0) {
62
- return false;
132
+ // Append the marker block after the existing content.
133
+ const head = [...lines];
134
+ while (head.length > 0 && head[head.length - 1].trim() === '') head.pop();
135
+ nextLines = head.length > 0
136
+ ? [...head, '', ...blockLines, '']
137
+ : [...blockLines, ''];
63
138
  }
64
139
 
65
- // Append marker block with missing entries
66
- const block = ['', newBlock, ''].join('\n');
67
- await writeFileAtomic(gitignorePath, content.trimEnd() + '\n' + block);
140
+ const next = nextLines.join(eol);
141
+ if (next === content) {
142
+ return false; // already up to date
143
+ }
144
+ await writeFileAtomic(gitignorePath, next);
68
145
  return true;
69
146
  }
70
147
 
71
148
  /**
72
- * Remove the UKit-managed block from .gitignore.
149
+ * Remove the UKit-managed block(s) from .gitignore.
73
150
  * Called by uninstall to leave the project in a clean state.
74
- * @returns {boolean} true if the block was found and removed
151
+ *
152
+ * Uses the same detection helpers as `ensureGitignore`, so anything
153
+ * `ensureGitignore` can write (including a CRLF-joined block) is removable, and
154
+ * a line that merely contains marker-like text is never matched.
155
+ *
156
+ * @returns {boolean} true if a block was found and removed
75
157
  */
76
158
  export async function removeGitignoreBlock(projectRoot) {
77
159
  const gitignorePath = path.join(projectRoot, '.gitignore');
@@ -83,35 +165,29 @@ export async function removeGitignoreBlock(projectRoot) {
83
165
  return false; // no .gitignore — nothing to remove
84
166
  }
85
167
 
86
- if (!content.includes(MARKER_START)) {
87
- return false; // no UKit block present
168
+ const { eol, lines } = parseGitignore(content);
169
+
170
+ let result = lines;
171
+ let cursor = 0;
172
+ let removed = false;
173
+ for (;;) {
174
+ const block = locateBlock(result, cursor);
175
+ if (!block) break;
176
+ removed = true;
177
+
178
+ // Drop the blank separator line that ensureGitignore prepended.
179
+ let from = block.startIdx;
180
+ if (from > 0 && result[from - 1] === '') from -= 1;
181
+
182
+ result = [...result.slice(0, from), ...result.slice(block.endIdx + 1)];
183
+ cursor = from;
88
184
  }
89
185
 
90
- // Walk lines and excise the block plus any leading blank line that
91
- // ensureGitignore appended before the marker.
92
- const lines = content.split('\n');
93
- const result = [];
94
- let inBlock = false;
95
-
96
- for (const line of lines) {
97
- if (line === MARKER_START) {
98
- inBlock = true;
99
- // Remove the blank separator line that ensureGitignore prepended.
100
- if (result.length > 0 && result[result.length - 1] === '') {
101
- result.pop();
102
- }
103
- continue;
104
- }
105
- if (inBlock) {
106
- if (line === MARKER_END) {
107
- inBlock = false;
108
- }
109
- continue;
110
- }
111
- result.push(line);
186
+ if (!removed) {
187
+ return false; // no UKit block present
112
188
  }
113
189
 
114
- const joined = result.join('\n').trimEnd();
115
- await writeFileAtomic(gitignorePath, joined ? joined + '\n' : '');
190
+ const joined = result.join(eol).trimEnd();
191
+ await writeFileAtomic(gitignorePath, joined ? joined + eol : '');
116
192
  return true;
117
193
  }
@@ -53,6 +53,19 @@ function buildTrackedManagedRelativePathSet(plan, projectRoot) {
53
53
  );
54
54
  }
55
55
 
56
+ export function adjustSummaryForSkips(plannedSummary, skippedByAction, skippedUpdates) {
57
+ if (!skippedUpdates) {
58
+ return plannedSummary;
59
+ }
60
+
61
+ return {
62
+ ...plannedSummary,
63
+ create: plannedSummary.create - (skippedByAction?.create ?? 0),
64
+ update: plannedSummary.update - (skippedByAction?.update ?? 0),
65
+ skip: plannedSummary.skip + skippedUpdates,
66
+ };
67
+ }
68
+
56
69
  function shouldAutoPruneObsoletePath(relativePath) {
57
70
  return AUTO_PRUNE_OBSOLETE_PATHS.has(relativePath)
58
71
  || AUTO_PRUNE_OBSOLETE_PREFIXES.some((prefix) => relativePath.startsWith(prefix));
@@ -271,7 +284,7 @@ export async function runInstallPipeline({
271
284
  const plannedSummary = summarizeDiff(diffResults);
272
285
 
273
286
  if (!dryRun) {
274
- const { writes, skippedUpdates } = await applyDiffResults(diffResults, {
287
+ const { writes, skippedUpdates, skippedByAction } = await applyDiffResults(diffResults, {
275
288
  backupRoot: pathConfig.backupRoot,
276
289
  projectRoot: pathConfig.projectRoot,
277
290
  });
@@ -291,16 +304,11 @@ export async function runInstallPipeline({
291
304
  managedLinkRelativePaths,
292
305
  });
293
306
 
294
- // Adjust summary to reflect link updates that were skipped for safety
295
- // (the planned diff counted them as 'update', but apply skipped them).
296
- const summary =
297
- skippedUpdates > 0
298
- ? {
299
- ...plannedSummary,
300
- update: plannedSummary.update - skippedUpdates,
301
- skip: plannedSummary.skip + skippedUpdates,
302
- }
303
- : plannedSummary;
307
+ // Adjust summary to reflect entries that were skipped for safety
308
+ // (the planned diff counted them as 'create'/'update', but apply skipped them).
309
+ // Subtract from the bucket the skipped entry was actually planned as, so the
310
+ // adjusted counts never go negative.
311
+ const summary = adjustSummaryForSkips(plannedSummary, skippedByAction, skippedUpdates);
304
312
 
305
313
  await writeInstallMetadata({
306
314
  installMetaPath: pathConfig.installMetaPath,
@@ -1,9 +1,15 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { createRequire } from 'node:module';
3
4
  import { buildRuntimePaths } from './runtimePaths.js';
4
5
 
6
+ const require = createRequire(import.meta.url);
7
+ const { version: PACKAGE_VERSION } = require('../../package.json');
8
+
5
9
  const VALID_AGENTS = new Set(['claude-code', 'codex', 'antigravity', 'opencode']);
6
10
 
11
+ const BLOCKED_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
12
+
7
13
  function isPlainObject(value) {
8
14
  return value !== null && typeof value === 'object' && !Array.isArray(value);
9
15
  }
@@ -15,6 +21,10 @@ function mergeObjects(base, override) {
15
21
 
16
22
  const merged = { ...base };
17
23
  for (const [key, value] of Object.entries(override)) {
24
+ if (BLOCKED_MERGE_KEYS.has(key)) {
25
+ continue;
26
+ }
27
+
18
28
  const current = merged[key];
19
29
  if (isPlainObject(current) && isPlainObject(value)) {
20
30
  merged[key] = mergeObjects(current, value);
@@ -49,7 +59,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
49
59
  const safeOverrides = isPlainObject(overrides) ? overrides : {};
50
60
 
51
61
  return mergeObjects({
52
- version: '1.5.2',
62
+ version: PACKAGE_VERSION,
53
63
  agent: 'claude-code',
54
64
  autonomy: {
55
65
  level: 'balanced',
@@ -124,7 +124,12 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
124
124
  regularPaths = [stateDir]; // always delete state dir — it's always UKit-owned
125
125
  linkPaths = [];
126
126
 
127
- const skipped = [];
127
+ // Two arrays for two unrelated meanings: invalidPaths are suspicious (forged
128
+ // or corrupt metadata pointing outside managed prefixes) and always worth a
129
+ // warning; linkChildPaths are routine (a tracked file under a tracked link
130
+ // that gets removed along with its parent) and must never warn.
131
+ const invalidPaths = [];
132
+ const linkChildPaths = [];
128
133
  const trackedEntries = [];
129
134
 
130
135
  for (const entry of installData.files) {
@@ -137,7 +142,7 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
137
142
 
138
143
  const normalizedRelative = normalizeToProjectRelative(projectRoot, absolutePath);
139
144
  if (!isAllowedProjectPath(normalizedRelative)) {
140
- skipped.push(relPath ?? normalizedRelative ?? absolutePath);
145
+ invalidPaths.push(relPath ?? normalizedRelative ?? absolutePath);
141
146
  continue;
142
147
  }
143
148
 
@@ -159,15 +164,15 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
159
164
  );
160
165
 
161
166
  if (isUnderTrackedLinkPath) {
162
- skipped.push(entry.normalizedRelative);
167
+ linkChildPaths.push(entry.normalizedRelative);
163
168
  continue;
164
169
  }
165
170
 
166
171
  regularPaths.push(entry.absolutePath);
167
172
  }
168
173
 
169
- if (skipped.length > 0 && dryRun) {
170
- console.warn('[UKit] Skipping invalid uninstall paths from install metadata:', skipped);
174
+ if (invalidPaths.length > 0) {
175
+ console.warn('[UKit] Skipping invalid uninstall paths from install metadata:', invalidPaths);
171
176
  }
172
177
  } else {
173
178
  // Old format (no files list): fall back to hardcoded managed paths.