@ngockhoale/ukit 2.4.3 → 2.5.1
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 +76 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +32 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +433 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +187 -3
- package/templates/.claude/hooks/project-important.sh +67 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +80 -48
- package/templates/.claude/settings.json +5 -0
- package/templates/.claude/ukit/runtime/project-important.mjs +384 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +7 -4
- package/templates/AGENTS.md +8 -0
- package/templates/PROJECT_IMPORTANT.md +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.5.1 - 2026-09-18
|
|
6
|
+
|
|
7
|
+
Post-release fixes from C20 reviewer advisories.
|
|
8
|
+
|
|
9
|
+
- **`projectImportant` symlink fallback**: the odd-open-error fallback imported
|
|
10
|
+
`stat` as `lstat`, which follows symlinks — a broken symlink degraded to `missing`
|
|
11
|
+
and a symlink-to-file to `unreadable` instead of the correct `unsafe-type`
|
|
12
|
+
(spec §6.1). Now uses real `lstat`; fixed in `src/core/projectImportant.js` and both
|
|
13
|
+
installed-runtime mirrors (parity-locked).
|
|
14
|
+
- **`inspectProjectImportant` guard**: a non-string `projectRoot` threw `TypeError`
|
|
15
|
+
before the try block instead of degrading to `unreadable`, violating the
|
|
16
|
+
"no documented entry point throws" contract. Guard added (src + mirrors).
|
|
17
|
+
- **Uninstall backup exhaustion surfaced**: when all 101 backup-name candidates are
|
|
18
|
+
occupied, `PROJECT_IMPORTANT.md` was silently preserved without a backup. Now emits
|
|
19
|
+
a `backupWarnings` line telling the user to free a slot and rerun.
|
|
20
|
+
- **Test tightening**: the packed-install smoke now asserts `project-important.sh`
|
|
21
|
+
occupies SessionStart index 0 (spec: must run first); corrected a stale "~5%
|
|
22
|
+
headroom" comment on the unpacked-size ceiling.
|
|
23
|
+
|
|
24
|
+
## 2.5.0 - 2026-09-18
|
|
25
|
+
|
|
26
|
+
C20 feature release (14 handoff tasks + 3-lane review): project-owner instructions via a
|
|
27
|
+
seeded root `PROJECT_IMPORTANT.md`, delivered once per context epoch on every supported
|
|
28
|
+
host.
|
|
29
|
+
|
|
30
|
+
- **`PROJECT_IMPORTANT.md` seed-once ownership**: `ukit install` creates the file only
|
|
31
|
+
when missing (`mergeStrategy: skip`, exclusive `'wx'` create, `lstat`-based existence);
|
|
32
|
+
it is never rewritten, merged, chmodded, tracked in `install.json.files`, or deleted —
|
|
33
|
+
reinstalls preserve it byte-for-byte, and delete + reinstall re-seeds.
|
|
34
|
+
- **Once-per-epoch injection**: Claude Code `SessionStart` (including
|
|
35
|
+
`source:'compact'` post-compact) and omp `session_start`/`session_compact` (steer only
|
|
36
|
+
— exactly one copy per epoch) render the file into advisory context; Codex and OpenCode
|
|
37
|
+
get a static read-the-file pointer in `AGENTS.md` (pointer, not runtime delivery).
|
|
38
|
+
- **Bounded deterministic render** (`src/core/projectImportant.js` + installed-runtime
|
|
39
|
+
mirror): 6,000-Unicode-code-point limit with an explicit oversized warning, strict
|
|
40
|
+
UTF-8 decode (malformed input is never injected), BOM excluded from count and body,
|
|
41
|
+
`O_NOFOLLOW`/`O_NONBLOCK` open so symlinks/FIFOs/devices are never followed or hung on,
|
|
42
|
+
and a byte-identical envelope (no volatile metadata) for identical input.
|
|
43
|
+
- **Fail-closed secret gate** (`src/core/sensitiveValueScanner.js`): the exact body to
|
|
44
|
+
be injected is scanned for high-confidence secret shapes; blocks report labels only —
|
|
45
|
+
never a value, excerpt, or hash.
|
|
46
|
+
- **Uninstall backup**: `ukit uninstall` backs the file up to a sibling
|
|
47
|
+
`PROJECT_IMPORTANT.md.ukit-backup[.n]` at project root (`COPYFILE_EXCL`, bounded suffix
|
|
48
|
+
walk, byte-identical reuse, dry-run plans only) and aborts before any managed deletion
|
|
49
|
+
if the backup cannot be made.
|
|
50
|
+
- **Honest-status surface**: `ukit status`/`ukit doctor` report per-check remediation
|
|
51
|
+
classes (`owner-action`/`install-repairable`/`advisory-host-limit`) and label local
|
|
52
|
+
prompt-cache stats as `Local p-cache` — provider telemetry is reported `unknown`, never
|
|
53
|
+
zero.
|
|
54
|
+
|
|
55
|
+
## 2.4.3 - 2026-09-18
|
|
56
|
+
|
|
57
|
+
C19 liveness-hardening release (23 handoff tasks): a full sweep of the shipped hook and
|
|
58
|
+
runtime surface against the freeze/stall/orphan classes behind the recurring "session
|
|
59
|
+
stands still mid-turn" reports, plus the regression instruments that keep them closed.
|
|
60
|
+
|
|
61
|
+
- **Bounded hook stdin staging (H01)**: `hook-input.sh`/`hook-input.mjs` runtime helpers
|
|
62
|
+
plus bounded-staging fallback blocks in all 16 hook wrapper templates — a producer that
|
|
63
|
+
never closes the pipe can no longer hold a hook open (SIGKILL at the staging bound).
|
|
64
|
+
- **Deadline-aware hot-hook filesystem work**: hook runtime modules self-arm on
|
|
65
|
+
`UKIT_HOOK_DEADLINE_MS` so a wedged read or lock wait exits inside the hook budget
|
|
66
|
+
instead of orphaning a node child.
|
|
67
|
+
- **Process-tree + lock containment**: bounded process-tree kill when a descendant escapes
|
|
68
|
+
its group, atomic quarantine of validated stale lock generations, owner-stamp failure
|
|
69
|
+
no longer runs the callback under an ownerless lock, and deep-freeze + containment
|
|
70
|
+
fail-closed for discovery snapshot reuse.
|
|
71
|
+
- **Liveness suite + classifier**: `src/diagnostics/classifyHang.js`, `tests/liveness/`,
|
|
72
|
+
and the `test:liveness` script — evidence-only hang classification (an external signal
|
|
73
|
+
is not a TERM→KILL leak) with regression scenarios pinned in tests.
|
|
74
|
+
- **Execution-ledger hardening**: bounded route-scoped ledger journal, overflow guard on
|
|
75
|
+
drain commits, journal-local locking, and the Stop coordinator
|
|
76
|
+
(`stop-coordinator.mjs`) with its extracted watchdog path.
|
|
77
|
+
- **Runtime shipping fix**: the install pipeline ships every packaged
|
|
78
|
+
`.claude/ukit/runtime` module via the `ukit-runtime-scripts` directory item (TASK-035),
|
|
79
|
+
closing the class where a new runtime module shipped to npm but never installed.
|
|
80
|
+
|
|
5
81
|
## 2.4.2 - 2026-09-16
|
|
6
82
|
|
|
7
83
|
Fix: the gateway resilience posture disabled the only recovery path a buffering gateway
|
package/README.md
CHANGED
|
@@ -69,6 +69,26 @@ If maintainers roll out a newer CLI build, the in-project workflow still stays t
|
|
|
69
69
|
- `.claude/ukit/.ukit/` — installer manifests, metadata, backups
|
|
70
70
|
- `.ukit/` — hidden shared runtime storage for config, cache, and cross-agent memory
|
|
71
71
|
- `docs/` — PROJECT / MEMORY / AI_HANDOFF / WORKLOG baseline
|
|
72
|
+
- `PROJECT_IMPORTANT.md` — your project-owner instruction file (see below)
|
|
73
|
+
|
|
74
|
+
## `PROJECT_IMPORTANT.md` — project-owner instructions
|
|
75
|
+
|
|
76
|
+
`ukit install` seeds a root `PROJECT_IMPORTANT.md` **once**. After that the file is
|
|
77
|
+
yours: UKit never rewrites, merges, formats, chmods, tracks, or deletes it — edits
|
|
78
|
+
survive every `ukit install` rerun byte-for-byte, and deleting it + reinstalling
|
|
79
|
+
seeds a fresh copy. It is also excluded from `ukit uninstall` deletion; uninstall
|
|
80
|
+
backs it up to a sibling `PROJECT_IMPORTANT.md.ukit-backup[.n]` file at project
|
|
81
|
+
root (never overwritten — a new suffix is chosen, and dry-run creates nothing).
|
|
82
|
+
|
|
83
|
+
Put your non-negotiable project rules there. On each session start your AI tool
|
|
84
|
+
receives its contents as advisory project-owner context — Claude Code and omp via
|
|
85
|
+
their session hooks (and once more after a compaction, which starts a new context
|
|
86
|
+
epoch), Codex and OpenCode via a static pointer in `AGENTS.md` (read-the-file
|
|
87
|
+
instructions, not a runtime hook). Keep the file at or below **6,000 Unicode code
|
|
88
|
+
points** — anything past that is truncated with an explicit warning, and the fix is
|
|
89
|
+
to shorten the file, not rerun `ukit install`. Do not put credentials or secrets in
|
|
90
|
+
it: a fail-closed sensitive-value scan runs on the exact body before injection and
|
|
91
|
+
blocks anything that looks like a key or token.
|
|
72
92
|
|
|
73
93
|
## UKit v1.3.1 Runtime
|
|
74
94
|
|
|
@@ -19,6 +19,20 @@ items:
|
|
|
19
19
|
packs:
|
|
20
20
|
- core
|
|
21
21
|
|
|
22
|
+
# Owner-instruction seed. `mergeStrategy: skip` gives seed-once semantics: UKit writes it
|
|
23
|
+
# only when the path is truly absent (exclusive 'wx' create in applyPlan), never rewrites,
|
|
24
|
+
# merges, formats, chmods or tracks it in install.json afterwards.
|
|
25
|
+
- id: project-important
|
|
26
|
+
type: config
|
|
27
|
+
sourceTemplate: PROJECT_IMPORTANT.md
|
|
28
|
+
targetPath: PROJECT_IMPORTANT.md
|
|
29
|
+
requires: []
|
|
30
|
+
mergeStrategy: skip
|
|
31
|
+
variables: []
|
|
32
|
+
enabledByDefault: true
|
|
33
|
+
packs:
|
|
34
|
+
- core
|
|
35
|
+
|
|
22
36
|
- id: docs-memory
|
|
23
37
|
type: config
|
|
24
38
|
sourceTemplate: docs/MEMORY.md
|
|
@@ -987,6 +1001,7 @@ items:
|
|
|
987
1001
|
- hook-block-dangerous
|
|
988
1002
|
- hook-handoff-model-guard
|
|
989
1003
|
- hook-auto-prune-bash
|
|
1004
|
+
- hook-project-important
|
|
990
1005
|
# Env block is post-merged by applyGatewayResilienceEnv (TASK-011) to add managed
|
|
991
1006
|
# gateway-resilience defaults without clobbering user values, so the diff ignores
|
|
992
1007
|
# the env block — byte-equality on the rest of the file is enough to flag an update.
|
|
@@ -1111,11 +1126,15 @@ items:
|
|
|
1111
1126
|
packs:
|
|
1112
1127
|
- core
|
|
1113
1128
|
|
|
1129
|
+
# requires: [ukit-runtime-scripts] is forward-declared for the shared sensitive-value
|
|
1130
|
+
# scanner (consumed by the guard once TASK-039 wires it). The guard is fail-closed, so
|
|
1131
|
+
# its runtime dependency must be installed first — keeps the ordering posture explicit.
|
|
1114
1132
|
- id: hook-sensitive-data-guard
|
|
1115
1133
|
type: hook
|
|
1116
1134
|
sourceTemplate: .claude/hooks/sensitive-data-guard.sh
|
|
1117
1135
|
targetPath: .claude/hooks/sensitive-data-guard.sh
|
|
1118
|
-
requires:
|
|
1136
|
+
requires:
|
|
1137
|
+
- ukit-runtime-scripts
|
|
1119
1138
|
mergeStrategy: overwrite_with_backup
|
|
1120
1139
|
variables: []
|
|
1121
1140
|
enabledByDefault: true
|
|
@@ -1255,6 +1274,18 @@ items:
|
|
|
1255
1274
|
packs:
|
|
1256
1275
|
- core
|
|
1257
1276
|
|
|
1277
|
+
- id: hook-project-important
|
|
1278
|
+
type: hook
|
|
1279
|
+
sourceTemplate: .claude/hooks/project-important.sh
|
|
1280
|
+
targetPath: .claude/hooks/project-important.sh
|
|
1281
|
+
requires:
|
|
1282
|
+
- ukit-runtime-scripts
|
|
1283
|
+
mergeStrategy: overwrite_with_backup
|
|
1284
|
+
variables: []
|
|
1285
|
+
enabledByDefault: true
|
|
1286
|
+
packs:
|
|
1287
|
+
- core
|
|
1288
|
+
|
|
1258
1289
|
- id: hook-worklog-session-start
|
|
1259
1290
|
type: hook
|
|
1260
1291
|
sourceTemplate: .claude/hooks/session-start.md
|
package/package.json
CHANGED
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
GATEWAY_RESILIENCE_ENV_DEFAULTS,
|
|
19
19
|
scanProfileResilienceEnv,
|
|
20
20
|
} from '../../core/gatewayResilienceEnv.js';
|
|
21
|
+
import {
|
|
22
|
+
inspectProjectImportant,
|
|
23
|
+
inspectProjectImportantWiring,
|
|
24
|
+
} from '../../core/projectImportant.js';
|
|
21
25
|
|
|
22
26
|
export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
|
|
23
27
|
const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway']);
|
|
@@ -103,6 +107,109 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
|
|
|
103
107
|
|
|
104
108
|
const ok = (v) => (v ? '✓' : '✗');
|
|
105
109
|
|
|
110
|
+
// TASK-044 — typed PROJECT_IMPORTANT.md checks (spec §14). Each failure
|
|
111
|
+
// carries a class: install-repairable | owner-action | advisory-host-limit.
|
|
112
|
+
const INSTALL_REPAIR = 'Run ukit install';
|
|
113
|
+
const piInspection = await inspectProjectImportant({
|
|
114
|
+
projectRoot,
|
|
115
|
+
config: runtimeConfigInspection.config,
|
|
116
|
+
});
|
|
117
|
+
const piWiring = await inspectProjectImportantWiring(projectRoot, { trackedPaths });
|
|
118
|
+
const piState = piInspection.state;
|
|
119
|
+
|
|
120
|
+
const contentCheck = (failedState, label, remedy) => ({
|
|
121
|
+
label,
|
|
122
|
+
passed: piState !== failedState && piState !== 'missing' && piState !== 'unsafe-type' && piState !== 'unreadable',
|
|
123
|
+
applicable: piState !== 'missing' && piState !== 'unsafe-type' && piState !== 'unreadable',
|
|
124
|
+
failed: piState === failedState,
|
|
125
|
+
remediationClass: 'owner-action',
|
|
126
|
+
remedy,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const projectChecks = [
|
|
130
|
+
{
|
|
131
|
+
label: 'PROJECT_IMPORTANT.md exists',
|
|
132
|
+
passed: piState !== 'missing',
|
|
133
|
+
failed: piState === 'missing',
|
|
134
|
+
remediationClass: 'install-repairable',
|
|
135
|
+
remedy: INSTALL_REPAIR,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
label: 'PROJECT_IMPORTANT.md is a regular non-symlink file',
|
|
139
|
+
passed: piState !== 'unsafe-type' && piState !== 'missing',
|
|
140
|
+
failed: piState === 'unsafe-type',
|
|
141
|
+
applicable: piState !== 'missing',
|
|
142
|
+
remediationClass: 'owner-action',
|
|
143
|
+
remedy: 'Replace PROJECT_IMPORTANT.md with a regular file at the project root.',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
label: 'PROJECT_IMPORTANT.md is readable',
|
|
147
|
+
passed: piState !== 'unreadable' && piState !== 'missing' && piState !== 'unsafe-type',
|
|
148
|
+
failed: piState === 'unreadable',
|
|
149
|
+
applicable: piState !== 'missing' && piState !== 'unsafe-type',
|
|
150
|
+
remediationClass: 'advisory-host-limit',
|
|
151
|
+
remedy: 'Check PROJECT_IMPORTANT.md file permissions; the host cannot read it.',
|
|
152
|
+
},
|
|
153
|
+
contentCheck('invalid-utf8', 'PROJECT_IMPORTANT.md is valid UTF-8',
|
|
154
|
+
'Save PROJECT_IMPORTANT.md as valid UTF-8 without changing its intended content.'),
|
|
155
|
+
contentCheck('unsafe-control', 'PROJECT_IMPORTANT.md free of unsupported control characters',
|
|
156
|
+
'Remove unsupported control characters from PROJECT_IMPORTANT.md.'),
|
|
157
|
+
contentCheck('empty', 'PROJECT_IMPORTANT.md is not empty',
|
|
158
|
+
'Add project-owner instructions to PROJECT_IMPORTANT.md.'),
|
|
159
|
+
contentCheck('oversized', 'PROJECT_IMPORTANT.md within 6,000 code points',
|
|
160
|
+
'Shorten PROJECT_IMPORTANT.md to 6,000 Unicode code points or fewer.'),
|
|
161
|
+
contentCheck('secret-blocked', 'PROJECT_IMPORTANT.md free of unallowlisted secrets',
|
|
162
|
+
'Redact the secret or explicitly allowlist it.'),
|
|
163
|
+
{
|
|
164
|
+
label: 'Runtime module .claude/ukit/runtime/project-important.mjs',
|
|
165
|
+
passed: piWiring.claude.runtimeModule,
|
|
166
|
+
failed: !piWiring.claude.runtimeModule,
|
|
167
|
+
remediationClass: 'install-repairable',
|
|
168
|
+
remedy: INSTALL_REPAIR,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
label: 'Hook wrapper .claude/hooks/project-important.sh installed and executable',
|
|
172
|
+
passed: piWiring.claude.hookInstalled && piWiring.claude.hookExecutable,
|
|
173
|
+
failed: !(piWiring.claude.hookInstalled && piWiring.claude.hookExecutable),
|
|
174
|
+
remediationClass: 'install-repairable',
|
|
175
|
+
remedy: INSTALL_REPAIR,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
label: 'Claude settings SessionStart order (project-important.sh first)',
|
|
179
|
+
passed: piWiring.claude.settingsWired,
|
|
180
|
+
failed: !piWiring.claude.settingsWired,
|
|
181
|
+
remediationClass: 'install-repairable',
|
|
182
|
+
remedy: INSTALL_REPAIR,
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
if (piWiring.omp.installed) {
|
|
186
|
+
projectChecks.push({
|
|
187
|
+
label: 'omp bridge session_start order (project-important.sh first)',
|
|
188
|
+
passed: piWiring.omp.wired,
|
|
189
|
+
failed: !piWiring.omp.wired,
|
|
190
|
+
remediationClass: 'install-repairable',
|
|
191
|
+
remedy: INSTALL_REPAIR,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (piWiring.codex.installed) {
|
|
195
|
+
projectChecks.push({
|
|
196
|
+
label: 'Codex fallback present (AGENTS.md owner-instructions section)',
|
|
197
|
+
passed: piWiring.codex.wired,
|
|
198
|
+
failed: !piWiring.codex.wired,
|
|
199
|
+
remediationClass: 'install-repairable',
|
|
200
|
+
remedy: INSTALL_REPAIR,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (piWiring.opencode.installed) {
|
|
204
|
+
projectChecks.push({
|
|
205
|
+
label: 'OpenCode fallback present (AGENTS.md owner-instructions section)',
|
|
206
|
+
passed: piWiring.opencode.wired,
|
|
207
|
+
failed: !piWiring.opencode.wired,
|
|
208
|
+
remediationClass: 'install-repairable',
|
|
209
|
+
remedy: INSTALL_REPAIR,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
106
213
|
const providerNames = Object.keys(providers.providers);
|
|
107
214
|
const providerStatus = providerNames
|
|
108
215
|
.map((name) => `${name}=${ok(providers.providers[name].supported)}`)
|
|
@@ -139,6 +246,12 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
|
|
|
139
246
|
}
|
|
140
247
|
console.log(`[UKit] ${ok(checks.allProvidersConfigured)} All providers configured`);
|
|
141
248
|
|
|
249
|
+
console.log('[UKit] Project rules checks:');
|
|
250
|
+
for (const check of projectChecks) {
|
|
251
|
+
if (check.applicable === false) continue;
|
|
252
|
+
console.log(`[UKit] ${ok(check.passed)} ${check.label}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
142
255
|
if (runtimeConfigInspection.errors.length > 0) {
|
|
143
256
|
console.log(`[UKit] Runtime config issues: ${runtimeConfigInspection.errors.join(' | ')}`);
|
|
144
257
|
}
|
|
@@ -254,8 +367,25 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
|
|
|
254
367
|
}
|
|
255
368
|
|
|
256
369
|
const allPassed = Object.values(checks).every(Boolean);
|
|
257
|
-
|
|
258
|
-
|
|
370
|
+
const failedProjectChecks = projectChecks.filter(
|
|
371
|
+
(check) => check.applicable !== false && !check.passed,
|
|
372
|
+
);
|
|
373
|
+
const blockingFailures = failedProjectChecks.filter(
|
|
374
|
+
(check) => check.remediationClass === 'install-repairable' || check.remediationClass === 'owner-action',
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
if (failedProjectChecks.length > 0) {
|
|
378
|
+
console.log('[UKit] Remedies:');
|
|
379
|
+
for (const check of failedProjectChecks) {
|
|
380
|
+
console.log(`[UKit] - [${check.remediationClass}] ${check.remedy}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!allPassed || blockingFailures.length > 0) {
|
|
385
|
+
console.log('[UKit] Some checks failed.');
|
|
386
|
+
if (!allPassed && blockingFailures.length === 0) {
|
|
387
|
+
console.log('[UKit] Re-run `ukit install` to repair install-managed files.');
|
|
388
|
+
}
|
|
259
389
|
process.exitCode = 1;
|
|
260
390
|
} else {
|
|
261
391
|
console.log('[UKit] All checks passed.');
|
|
@@ -42,10 +42,28 @@ export async function runUninstall({ projectRoot, argv = [] }) {
|
|
|
42
42
|
for (const p of result.wouldRemove) {
|
|
43
43
|
console.log(` - ${path.relative(projectRoot, p)}`);
|
|
44
44
|
}
|
|
45
|
+
for (const p of result.preservedPaths ?? []) {
|
|
46
|
+
console.log(`[UKit] Preserved ${path.basename(p)}.`);
|
|
47
|
+
}
|
|
48
|
+
if (result.wouldBackup && (result.backupPaths ?? []).length > 0) {
|
|
49
|
+
console.log(`[UKit] Would back up to ${path.basename(result.backupPaths[0])}.`);
|
|
50
|
+
}
|
|
51
|
+
for (const warning of result.backupWarnings ?? []) {
|
|
52
|
+
console.log(warning);
|
|
53
|
+
}
|
|
45
54
|
console.log(`[UKit] Would remove ${result.wouldRemove.length} path(s). Run without --dry-run to actually uninstall.`);
|
|
46
55
|
return;
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
for (const p of result.preservedPaths ?? []) {
|
|
59
|
+
console.log(`[UKit] Preserved ${path.basename(p)}.`);
|
|
60
|
+
}
|
|
61
|
+
for (const p of result.backupPaths ?? []) {
|
|
62
|
+
console.log(`[UKit] Backup: ${path.basename(p)}`);
|
|
63
|
+
}
|
|
64
|
+
for (const warning of result.backupWarnings ?? []) {
|
|
65
|
+
console.log(warning);
|
|
66
|
+
}
|
|
49
67
|
console.log(`[UKit] Uninstall complete. Removed ${result.removed}/${result.attempted} managed paths.`);
|
|
50
68
|
console.log('[UKit] Note: docs/PROJECT.md, docs/MEMORY.md, docs/AI_HANDOFF/, docs/WORKLOG.md contain user content and were preserved. Delete manually if needed.');
|
|
51
69
|
}
|
package/src/core/applyPlan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { writeFileAtomic, copyFileSafe, createDirectoryLink, removeLinkOnly } from './fileOps.js';
|
|
3
|
+
import { writeFileAtomic, writeFileExclusive, copyFileSafe, createDirectoryLink, removeLinkOnly } from './fileOps.js';
|
|
4
4
|
|
|
5
5
|
// legacy: '.antigravity/' stays TCC-protected — Antigravity adapter removed in v2.2.0, but a
|
|
6
6
|
// stale install may still have the dir and must not lose TCC protection.
|
|
@@ -107,9 +107,24 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
|
|
|
107
107
|
)
|
|
108
108
|
: entry.renderedContent;
|
|
109
109
|
|
|
110
|
+
// Race-safe seed: a `create` entry with `mergeStrategy: skip` must not overwrite a
|
|
111
|
+
// target that appeared after the diff ran. Exclusive 'wx' create fails with EEXIST
|
|
112
|
+
// instead — that becomes a skipped create (no rewrite, no chmod, no tracked write),
|
|
113
|
+
// and install continues. Temp+rename is NOT used here because rename overwrites.
|
|
114
|
+
const exclusiveSkipCreate = entry.action === 'create' && entry.mergeStrategy === 'skip';
|
|
115
|
+
|
|
110
116
|
try {
|
|
111
|
-
|
|
117
|
+
if (exclusiveSkipCreate) {
|
|
118
|
+
await writeFileExclusive(entry.targetPath, nextContent);
|
|
119
|
+
} else {
|
|
120
|
+
await writeFileAtomic(entry.targetPath, nextContent);
|
|
121
|
+
}
|
|
112
122
|
} catch (writeError) {
|
|
123
|
+
if (writeError.code === 'EEXIST' && exclusiveSkipCreate) {
|
|
124
|
+
skippedUpdates += 1;
|
|
125
|
+
skippedByAction.create += 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
113
128
|
if (
|
|
114
129
|
(writeError.code === 'EPERM' || writeError.code === 'EACCES') &&
|
|
115
130
|
isTccProtectedPath(entry.targetPath)
|
package/src/core/diffPlan.js
CHANGED
|
@@ -132,6 +132,37 @@ function resolveFileAction(entry, existingContent) {
|
|
|
132
132
|
return { ...entry, exists, action, existingContent };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// A `mergeStrategy: skip` target must never be classified as "missing" just because it
|
|
136
|
+
// cannot be READ — a directory, broken symlink, FIFO/device, or unreadable (EACCES) file
|
|
137
|
+
// at the seed path all mean "exists, do not touch". readFileOrNull would turn every one
|
|
138
|
+
// of those into null → 'create' → an overwrite attempt. Existence for skip entries is
|
|
139
|
+
// decided by lstat (no symlink follow, no device open — reading a FIFO would hang).
|
|
140
|
+
async function resolveSkipEntryAction(entry) {
|
|
141
|
+
let stat;
|
|
142
|
+
try {
|
|
143
|
+
stat = await fs.lstat(entry.targetPath);
|
|
144
|
+
} catch {
|
|
145
|
+
// ENOENT (or a stat failure we cannot distinguish from it) is the only state that
|
|
146
|
+
// means "seed me"; the apply step still uses an exclusive 'wx' create for the race.
|
|
147
|
+
return { ...entry, exists: false, action: 'create', existingContent: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Only a regular, readable file can prove byte-equality for an 'unchanged' verdict.
|
|
151
|
+
// Anything else — directory, symlink (valid or broken), FIFO, socket, device, or a
|
|
152
|
+
// file we cannot read — is simply 'skip': present, owner-owned, hands off.
|
|
153
|
+
let existingContent = null;
|
|
154
|
+
if (stat.isFile() && !stat.isSymbolicLink()) {
|
|
155
|
+
existingContent = await readFileOrNull(
|
|
156
|
+
entry.targetPath,
|
|
157
|
+
Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (existingContent === null) {
|
|
161
|
+
return { ...entry, exists: true, action: 'skip', existingContent: null };
|
|
162
|
+
}
|
|
163
|
+
return resolveFileAction(entry, existingContent);
|
|
164
|
+
}
|
|
165
|
+
|
|
135
166
|
export async function diffInstallPlan(plan) {
|
|
136
167
|
// Check all entries in parallel
|
|
137
168
|
const results = await Promise.all(
|
|
@@ -141,6 +172,10 @@ export async function diffInstallPlan(plan) {
|
|
|
141
172
|
return { ...entry, exists: action !== 'create', action, existingContent: null };
|
|
142
173
|
}
|
|
143
174
|
|
|
175
|
+
if (entry.mergeStrategy === 'skip') {
|
|
176
|
+
return resolveSkipEntryAction(entry);
|
|
177
|
+
}
|
|
178
|
+
|
|
144
179
|
const existingContent = await readFileOrNull(
|
|
145
180
|
entry.targetPath,
|
|
146
181
|
Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
|
package/src/core/fileOps.js
CHANGED
|
@@ -128,6 +128,32 @@ export async function copyFileSafe(fromPath, toPath) {
|
|
|
128
128
|
await fs.copyFile(fromPath, toPath);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Exclusive-create write ('wx'): succeeds only when `filePath` does not already exist.
|
|
133
|
+
* Used for seed-once (`mergeStrategy: skip`) creates so a file that appears between
|
|
134
|
+
* diff and apply is never overwritten — the caller maps EEXIST to a skipped create.
|
|
135
|
+
* Never used as a temp+rename pair: rename could overwrite a target that just appeared.
|
|
136
|
+
* @param {string} filePath
|
|
137
|
+
* @param {string|Buffer} content - raw bytes; no encoding/normalization is applied
|
|
138
|
+
*/
|
|
139
|
+
export async function writeFileExclusive(filePath, content) {
|
|
140
|
+
await ensureDir(path.dirname(filePath));
|
|
141
|
+
await fs.writeFile(filePath, content, { flag: 'wx' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Raw-byte copy into a path that must not already exist (exclusive destination create,
|
|
146
|
+
* `fs.constants.COPYFILE_EXCL`). No decode/re-encode — the destination is byte-identical
|
|
147
|
+
* to the source. Source is opened by path; callers needing no-follow semantics must
|
|
148
|
+
* verify the source type before calling. EEXIST propagates for the caller to handle.
|
|
149
|
+
* @param {string} fromPath
|
|
150
|
+
* @param {string} toPath
|
|
151
|
+
*/
|
|
152
|
+
export async function copyFileRawExclusive(fromPath, toPath) {
|
|
153
|
+
await ensureDir(path.dirname(toPath));
|
|
154
|
+
await fs.copyFile(fromPath, toPath, fs.constants.COPYFILE_EXCL);
|
|
155
|
+
}
|
|
156
|
+
|
|
131
157
|
export async function writeJson(filePath, data) {
|
|
132
158
|
await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
133
159
|
}
|