vibecoding-devtools 1.0.1 → 1.0.2
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/README.md +4 -4
- package/installer/cli.mjs +0 -0
- package/installer/execute.mjs +386 -386
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,12 +10,12 @@ instalados das fontes oficiais.
|
|
|
10
10
|
## Instalação
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npx vibecoding-devtools@1.0.
|
|
14
|
-
npx vibecoding-devtools@1.0.
|
|
15
|
-
npx vibecoding-devtools@1.0.
|
|
13
|
+
npx vibecoding-devtools@1.0.2 install # instala pelo painel
|
|
14
|
+
npx vibecoding-devtools@1.0.2 update # reutiliza as escolhas salvas
|
|
15
|
+
npx vibecoding-devtools@1.0.2 uninstall # remove só o que o kit instalou
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Alternativa pelo GitHub: `npx github:Or4cu1o/vibecoding-devtools#v1.0.
|
|
18
|
+
Alternativa pelo GitHub: `npx github:Or4cu1o/vibecoding-devtools#v1.0.2 install` (aceita os mesmos comandos).
|
|
19
19
|
|
|
20
20
|
Requer Node 22+ e git. O instalador detecta Claude Code, Codex e Antigravity, mostra o plano e só grava depois da
|
|
21
21
|
confirmação. Use `--dry-run` para apenas conferir o plano. Detalhes no [guia do instalador](docs/installer.md).
|
package/installer/cli.mjs
CHANGED
|
File without changes
|
package/installer/execute.mjs
CHANGED
|
@@ -1,386 +1,386 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { isUtf8 } from 'node:buffer';
|
|
6
|
-
import { readState, saveState, hashContent, writeAtomic, withLock, kitVersion } from './state.mjs';
|
|
7
|
-
import { mergeJson, jsonMergeSnapshot } from './json-merge.mjs';
|
|
8
|
-
|
|
9
|
-
export const START = '<!-- vibecoding-devtools:start -->';
|
|
10
|
-
export const END = '<!-- vibecoding-devtools:end -->';
|
|
11
|
-
const report = (dryRun) => ({ ok: true, dryRun, actions: [], conflicts: [], warnings: [], errors: [] });
|
|
12
|
-
const exists = (p) => {
|
|
13
|
-
try { return fs.lstatSync(p); }
|
|
14
|
-
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
15
|
-
};
|
|
16
|
-
const marker = (item) => item.type === 'block' ? item.marker ?? START : null;
|
|
17
|
-
const identity = (item) => item.type === 'command'
|
|
18
|
-
? JSON.stringify([item.type, item.cmd, item.args, item.cwd, item.cli, item.owner])
|
|
19
|
-
: JSON.stringify([item.type, path.resolve(item.destination), marker(item)]);
|
|
20
|
-
const confirmed = ({ pending, operation, previous, hashBefore, diskHash, ...item }) => item;
|
|
21
|
-
|
|
22
|
-
function checkPath(destination) {
|
|
23
|
-
for (let p = path.resolve(destination); ; p = path.dirname(p)) {
|
|
24
|
-
if (exists(p)?.isSymbolicLink()) throw new Error(`Symbolic link not allowed: ${p}`);
|
|
25
|
-
if (p === path.dirname(p)) break;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function utf8(bytes) {
|
|
30
|
-
if (!isUtf8(bytes) || bytes.subarray(0, 2).equals(Buffer.from([255, 254]))
|
|
31
|
-
|| bytes.subarray(0, 2).equals(Buffer.from([254, 255]))) return null;
|
|
32
|
-
return bytes.toString('utf8');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function blockRange(text) {
|
|
36
|
-
const start = text.indexOf(START);
|
|
37
|
-
const end = text.indexOf(END);
|
|
38
|
-
if (start < 0 && end < 0) return null;
|
|
39
|
-
if (start < 0 || end < start || text.indexOf(START, start + START.length) >= 0
|
|
40
|
-
|| text.indexOf(END, end + END.length) >= 0) throw new Error('Invalid or duplicate block markers');
|
|
41
|
-
return { start, end: end + END.length, content: text.slice(start, end + END.length) };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function withoutBlock(text, block, item) {
|
|
45
|
-
let start = block.start;
|
|
46
|
-
if (item.separator && text.slice(start - item.separator.length, start) === item.separator) {
|
|
47
|
-
start -= item.separator.length;
|
|
48
|
-
}
|
|
49
|
-
return text.slice(0, start) + text.slice(block.end);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Remove only known intact blocks when validating ownership of their containing files.
|
|
53
|
-
function fileBase(current, destination, items) {
|
|
54
|
-
const record = items.find((i) => i.type === 'block' && i.destination === destination);
|
|
55
|
-
if (!record) return { bytes: current, suffix: '' };
|
|
56
|
-
const text = utf8(current);
|
|
57
|
-
if (text === null) throw new Error('Block file is not valid UTF-8');
|
|
58
|
-
const block = blockRange(text);
|
|
59
|
-
if (!block) return { bytes: current, suffix: '' };
|
|
60
|
-
if (record.pending || hashContent(block.content) !== record.hash) {
|
|
61
|
-
throw new Error('Modified or pending block; file retained');
|
|
62
|
-
}
|
|
63
|
-
return { bytes: Buffer.from(withoutBlock(text, block, record)), suffix: (record.separator ?? '') + block.content };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function resolveCommand(cmd, options) {
|
|
67
|
-
const win = process.platform === 'win32';
|
|
68
|
-
const envValue = (name) => Object.entries(options.env).find(([k]) => k.toUpperCase() === name)?.[1];
|
|
69
|
-
const extensions = win && !path.extname(cmd)
|
|
70
|
-
? (envValue('PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : [''];
|
|
71
|
-
const directories = /[\\/]/.test(cmd) || path.isAbsolute(cmd) ? ['']
|
|
72
|
-
: (envValue('PATH') ?? '').split(path.delimiter).filter(Boolean).map((p) => p.replace(/^"(.*)"$/, '$1'));
|
|
73
|
-
for (const directory of directories) for (const ext of extensions) {
|
|
74
|
-
const file = path.resolve(options.cwd, directory, cmd + ext);
|
|
75
|
-
try {
|
|
76
|
-
if (!fs.statSync(file).isFile()) continue;
|
|
77
|
-
if (!win) fs.accessSync(file, fs.constants.X_OK);
|
|
78
|
-
return file;
|
|
79
|
-
} catch { /* try the next extension or directory */ }
|
|
80
|
-
}
|
|
81
|
-
throw new Error(`Command not found in PATH: ${cmd}`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function command(action, home) {
|
|
85
|
-
if (typeof action.cmd !== 'string' || !action.cmd || !Array.isArray(action.args)
|
|
86
|
-
|| action.args.some((a) => typeof a !== 'string')) throw new Error('Command requires cmd and a string array for args');
|
|
87
|
-
const env = { ...process.env };
|
|
88
|
-
for (const [key, value] of Object.entries({ HOME: home, USERPROFILE: home, ...action.env })) {
|
|
89
|
-
if (process.platform === 'win32') for (const existing of Object.keys(env)) {
|
|
90
|
-
if (existing.toUpperCase() === key.toUpperCase()) delete env[existing];
|
|
91
|
-
}
|
|
92
|
-
env[key] = value;
|
|
93
|
-
}
|
|
94
|
-
const options = { cwd: action.cwd ?? home, env, encoding: 'utf8', shell: false, windowsHide: true };
|
|
95
|
-
let cmd = resolveCommand(action.cmd, options);
|
|
96
|
-
let args = action.args;
|
|
97
|
-
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd)) {
|
|
98
|
-
if ([cmd, ...args].some((a) => /["%!\r\n\0]/.test(a))) throw new Error('Unsafe argument for cmd.exe');
|
|
99
|
-
args = ['/d', '/s', '/c', `"${[cmd, ...args].map((a) => `"${a}"`).join(' ')}"`];
|
|
100
|
-
cmd = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe');
|
|
101
|
-
options.windowsVerbatimArguments = true;
|
|
102
|
-
}
|
|
103
|
-
return { cmd, args, options };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function run(prepared, action) {
|
|
107
|
-
const { cmd, args, options } = prepared;
|
|
108
|
-
const r = spawnSync(cmd, args, options);
|
|
109
|
-
if (r.error) throw r.error;
|
|
110
|
-
if (r.status !== 0) throw new Error(`Command failed (${r.status ?? r.signal}): ${action.cmd}\n${r.stderr ?? ''}`);
|
|
111
|
-
return { stdout: r.stdout, stderr: r.stderr };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function session(home, dryRun, r, work) {
|
|
115
|
-
try {
|
|
116
|
-
return withLock(home, () => {
|
|
117
|
-
const state = readState({ home });
|
|
118
|
-
const persist = () => { if (!dryRun) saveState(state, { home }); };
|
|
119
|
-
const save = (item) => {
|
|
120
|
-
const i = state.items.findIndex((x) => identity(x) === identity(item));
|
|
121
|
-
if (i < 0) state.items.push(item); else state.items[i] = item;
|
|
122
|
-
persist();
|
|
123
|
-
};
|
|
124
|
-
const forget = (item) => {
|
|
125
|
-
const i = state.items.findIndex((x) => identity(x) === identity(item));
|
|
126
|
-
if (i >= 0) state.items.splice(i, 1);
|
|
127
|
-
persist();
|
|
128
|
-
};
|
|
129
|
-
// Never repeat pending commands: disk hashes cannot prove external process effects.
|
|
130
|
-
for (const item of [...state.items]) if (item.pending) {
|
|
131
|
-
if (item.type === 'command') {
|
|
132
|
-
r.warnings.push({ item, reason: 'Pending command: check the third party before retrying or uninstalling' });
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
checkPath(item.destination);
|
|
136
|
-
const st = exists(item.destination);
|
|
137
|
-
const current = st?.isFile() ? fs.readFileSync(item.destination) : null;
|
|
138
|
-
const hash = current ? hashContent(current) : null;
|
|
139
|
-
if ((!st || st.isFile()) && hash === item.diskHash) {
|
|
140
|
-
if (item.operation === 'remove') forget(item); else save(confirmed(item));
|
|
141
|
-
} else if ((!st || st.isFile()) && hash === item.hashBefore) {
|
|
142
|
-
if (item.previous) save(item.previous); else forget(item);
|
|
143
|
-
} else r.warnings.push({ item, reason: 'Pending item has unexpected content; retained for review' });
|
|
144
|
-
}
|
|
145
|
-
work({ state, save, forget, persist, virtualFiles: new Map() });
|
|
146
|
-
return r;
|
|
147
|
-
}, { dryRun });
|
|
148
|
-
} catch (e) {
|
|
149
|
-
r.ok = false;
|
|
150
|
-
r.errors.push({ message: e.message });
|
|
151
|
-
return r;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function effect(item, previous, current, content, context, r, action, apply, status = 'executed') {
|
|
156
|
-
const pending = { ...item, pending: true, operation: status === 'removed' ? 'remove' : 'install',
|
|
157
|
-
previous: previous ?? null, hashBefore: current === null ? null : hashContent(current),
|
|
158
|
-
diskHash: content === null ? null : hashContent(content) };
|
|
159
|
-
context.save(pending);
|
|
160
|
-
const record = { ...action, status: r.dryRun ? 'planned' : 'pending' };
|
|
161
|
-
r.actions.push(record);
|
|
162
|
-
if (!r.dryRun) apply();
|
|
163
|
-
if (status === 'removed') context.forget(item); else context.save(item);
|
|
164
|
-
record.status = r.dryRun ? 'planned' : status;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Return { ok, dryRun, actions, conflicts, warnings, errors }; skip identical items and conflicts.
|
|
168
|
-
export function execute(plan, { dryRun = false, force = false, home = os.homedir(), choices } = {}) {
|
|
169
|
-
const r = report(dryRun);
|
|
170
|
-
return session(home, dryRun, r, (context) => {
|
|
171
|
-
const { state } = context;
|
|
172
|
-
if (choices && (state.version !== kitVersion || JSON.stringify(state.choices) !== JSON.stringify(choices))) {
|
|
173
|
-
state.choices = structuredClone(choices);
|
|
174
|
-
context.persist();
|
|
175
|
-
}
|
|
176
|
-
const conflict = (action, reason) => { r.conflicts.push({ ...action, reason }); r.ok = false; };
|
|
177
|
-
const removals = plan.filter((action) => action.operation === 'remove');
|
|
178
|
-
if (removals.some((action) => action.owner !== 'kit' || action.type === 'command')) {
|
|
179
|
-
throw new Error('Update removes only kit-owned filesystem items');
|
|
180
|
-
}
|
|
181
|
-
const obsolete = removals.map((action) => state.items.find((i) => identity(i) === identity(action)))
|
|
182
|
-
.filter((item) => item?.owner === 'kit').map((item) => ({ ...item, operation: 'remove', situation: 'remove' }));
|
|
183
|
-
const registrations = obsolete.filter((item) => item.type === 'json-merge');
|
|
184
|
-
const files = obsolete.filter((item) => item.type !== 'json-merge');
|
|
185
|
-
removeItems(context, r, registrations, home);
|
|
186
|
-
if (!r.ok) return;
|
|
187
|
-
const apply = (action) => {
|
|
188
|
-
if (!['kit', 'ecc', 'impeccable'].includes(action.owner) || !action.cli) throw new Error('Action requires owner and cli');
|
|
189
|
-
if (action.operation === 'remove') {
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
if (action.situation === 'same') { r.actions.push({ ...action, status: 'same' }); return; }
|
|
193
|
-
if (action.situation === 'conflict' && !force) { conflict(action, 'Conflict identified in plan'); return; }
|
|
194
|
-
if (action.type === 'command') {
|
|
195
|
-
const item = { ...action, cwd: action.cwd ?? home };
|
|
196
|
-
const previous = state.items.find((i) => identity(i) === identity(item));
|
|
197
|
-
if (previous?.pending) { conflict(action, 'Pending command; check effects before retrying'); return; }
|
|
198
|
-
if (previous && previous.version === item.version
|
|
199
|
-
&& JSON.stringify(previous.env ?? {}) === JSON.stringify(item.env ?? {})) {
|
|
200
|
-
r.actions.push({ ...action, status: 'same' });
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
const prepared = command(action, home);
|
|
204
|
-
effect(item, previous, null, null, context, r, action, () => {
|
|
205
|
-
Object.assign(r.actions.at(-1), run(prepared, action));
|
|
206
|
-
});
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
if (!['copy', 'write', 'block', 'json-merge'].includes(action.type)) throw new Error(`Unknown type: ${action.type}`);
|
|
210
|
-
if (!['copy', 'json-merge'].includes(action.type) && typeof action.content !== 'string'
|
|
211
|
-
&& !(action.type === 'write' && Buffer.isBuffer(action.content))) throw new Error('Action requires text content');
|
|
212
|
-
const destination = path.resolve(action.destination);
|
|
213
|
-
checkPath(destination);
|
|
214
|
-
if (action.type === 'copy') {
|
|
215
|
-
const st = fs.lstatSync(action.source);
|
|
216
|
-
if (st.isSymbolicLink()) throw new Error(`Source is a symbolic link: ${action.source}`);
|
|
217
|
-
if (st.isDirectory()) {
|
|
218
|
-
const relative = path.relative(path.resolve(action.source), destination);
|
|
219
|
-
if (!relative || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
|
|
220
|
-
throw new Error('Copy destination cannot be inside source');
|
|
221
|
-
}
|
|
222
|
-
if (exists(destination) && !exists(destination).isDirectory()) { conflict(action, 'Destination is not a directory'); return; }
|
|
223
|
-
if (!dryRun) fs.mkdirSync(destination, { recursive: true });
|
|
224
|
-
r.actions.push({ ...action, destination, status: dryRun ? 'planned' : 'executed' });
|
|
225
|
-
for (const name of fs.readdirSync(action.source).sort()) apply({ ...action, situation: undefined,
|
|
226
|
-
source: path.join(action.source, name), destination: path.join(destination, name) });
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
if (!st.isFile()) throw new Error('Source is not a regular file');
|
|
230
|
-
}
|
|
231
|
-
const st = exists(destination);
|
|
232
|
-
if (st && !st.isFile()) { conflict(action, 'Destination is not a regular file'); return; }
|
|
233
|
-
const key = { ...action, destination };
|
|
234
|
-
const previous = state.items.find((i) => identity(i) === identity(key));
|
|
235
|
-
if (previous?.pending) { conflict(action, 'Pending item; unexpected content'); return; }
|
|
236
|
-
const current = st ? fs.readFileSync(destination) : null;
|
|
237
|
-
let content;
|
|
238
|
-
let extra;
|
|
239
|
-
if (action.type === 'json-merge') {
|
|
240
|
-
let merged;
|
|
241
|
-
try { merged = mergeJson(current, action); }
|
|
242
|
-
catch (e) { conflict(action, e.message); return; }
|
|
243
|
-
if (merged.before.hash === merged.after.hash) { r.actions.push({ ...action, status: 'same' }); return; }
|
|
244
|
-
if (merged.before.count && !force && (!previous || merged.before.hash !== previous.hash)) {
|
|
245
|
-
conflict(action, 'Preexisting or modified JSON entries'); return;
|
|
246
|
-
}
|
|
247
|
-
content = merged.content;
|
|
248
|
-
extra = { hash: merged.after.hash, format: action.format, scriptPaths: action.scriptPaths,
|
|
249
|
-
scriptReferences: action.scriptReferences,
|
|
250
|
-
created: previous?.created ?? !st, createdPaths: previous?.createdPaths ?? merged.createdPaths };
|
|
251
|
-
} else if (action.type === 'block') {
|
|
252
|
-
const text = current ? utf8(current) : '';
|
|
253
|
-
if (text === null) { conflict(action, 'File is not valid UTF-8'); return; }
|
|
254
|
-
const block = blockRange(text);
|
|
255
|
-
const replacement = `${START}\n${action.content}\n${END}`;
|
|
256
|
-
if (blockRange(replacement)?.content !== replacement) throw new Error('Content contains reserved markers');
|
|
257
|
-
if (block?.content === replacement) { r.actions.push({ ...action, status: 'same' }); return; }
|
|
258
|
-
if (block && !force && (!previous || hashContent(block.content) !== previous.hash)) {
|
|
259
|
-
conflict(action, 'Preexisting or modified block'); return;
|
|
260
|
-
}
|
|
261
|
-
const separator = text && !text.endsWith('\n') ? '\n' : '';
|
|
262
|
-
content = block ? text.slice(0, block.start) + replacement + text.slice(block.end) : text + separator + replacement;
|
|
263
|
-
extra = { hash: hashContent(replacement), marker: START,
|
|
264
|
-
separator: block ? previous?.separator ?? '' : separator, created: previous?.created ?? !st };
|
|
265
|
-
} else {
|
|
266
|
-
const expected = action.type === 'copy' ? fs.readFileSync(action.source) : Buffer.from(action.content);
|
|
267
|
-
let base;
|
|
268
|
-
try { base = current ? fileBase(current, destination, state.items) : { bytes: null, suffix: '' }; }
|
|
269
|
-
catch (e) { conflict(action, e.message); return; }
|
|
270
|
-
if (base.bytes?.equals(expected)) { r.actions.push({ ...action, status: 'same' }); return; }
|
|
271
|
-
if (st && !force && (!previous || hashContent(base.bytes) !== previous.hash)) {
|
|
272
|
-
conflict(action, 'Preexisting or modified file'); return;
|
|
273
|
-
}
|
|
274
|
-
content = Buffer.concat([expected, Buffer.from(base.suffix)]);
|
|
275
|
-
extra = { hash: hashContent(expected) };
|
|
276
|
-
}
|
|
277
|
-
const item = { type: action.type, destination, owner: action.owner, cli: action.cli, ...extra };
|
|
278
|
-
effect(item, previous, current, content, context, r, { ...action, destination },
|
|
279
|
-
() => writeAtomic(destination, content));
|
|
280
|
-
if (dryRun) context.virtualFiles.set(destination, content);
|
|
281
|
-
};
|
|
282
|
-
for (const action of plan) {
|
|
283
|
-
try { apply(action); }
|
|
284
|
-
catch (e) { r.ok = false; r.errors.push({ action, message: e.message }); break; }
|
|
285
|
-
}
|
|
286
|
-
// Retarget registrations only after installing their replacement runtime.
|
|
287
|
-
// Cleanup must see the resulting registrations, including retained conflicts.
|
|
288
|
-
if (r.ok) removeItems(context, r, files, home);
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Remove blocks before files and revalidate pending items before any effects.
|
|
293
|
-
export function uninstall({ home = os.homedir(), dryRun = false } = {}) {
|
|
294
|
-
const r = report(dryRun);
|
|
295
|
-
return session(home, dryRun, r, (context) => {
|
|
296
|
-
removeItems(context, r, context.state.items, home);
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function runtimeRegistration(item, context) {
|
|
301
|
-
if (!['copy', 'write'].includes(item.type)) return null;
|
|
302
|
-
const destination = path.resolve(item.destination);
|
|
303
|
-
for (const registration of context.state.items.filter((entry) => entry.type === 'json-merge')) {
|
|
304
|
-
const paths = [...new Set([...(registration.scriptPaths ?? []),
|
|
305
|
-
...(registration.previous?.scriptPaths ?? [])])].filter((script) => {
|
|
306
|
-
const runtime = path.dirname(path.resolve(script));
|
|
307
|
-
return destination === path.resolve(script) || destination.startsWith(path.join(runtime, 'lib') + path.sep);
|
|
308
|
-
});
|
|
309
|
-
if (!paths.length) continue;
|
|
310
|
-
if (registration.pending) return registration;
|
|
311
|
-
try {
|
|
312
|
-
checkPath(registration.destination);
|
|
313
|
-
const current = context.virtualFiles.has(registration.destination)
|
|
314
|
-
? context.virtualFiles.get(registration.destination)
|
|
315
|
-
: exists(registration.destination) ? fs.readFileSync(registration.destination) : null;
|
|
316
|
-
if (current === null) continue;
|
|
317
|
-
// scriptPaths also contains legacy paths used for ownership detection.
|
|
318
|
-
// Inspect the actual commands so a successful retarget releases those paths.
|
|
319
|
-
if (jsonMergeSnapshot(current, {
|
|
320
|
-
...registration, scriptPaths: paths,
|
|
321
|
-
scriptReferences: (registration.scriptReferences ?? []).filter((reference) =>
|
|
322
|
-
paths.some((script) => path.resolve(script) === path.resolve(path.dirname(registration.destination),
|
|
323
|
-
'..', reference.replace(/^\$\{CLAUDE_PROJECT_DIR\}\//, '')))),
|
|
324
|
-
}).count) return registration;
|
|
325
|
-
} catch { return registration; } // Unreadable or invalid registrations remain dependencies.
|
|
326
|
-
}
|
|
327
|
-
return null;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function removeItems(context, r, items, home) {
|
|
331
|
-
const dryRun = r.dryRun;
|
|
332
|
-
const isRegistration = (item) => ['block', 'json-merge'].includes(item.type);
|
|
333
|
-
const order = [...items].reverse().sort((a, b) => Number(isRegistration(b)) - Number(isRegistration(a)));
|
|
334
|
-
const { virtualFiles } = context;
|
|
335
|
-
for (const item of order) {
|
|
336
|
-
try {
|
|
337
|
-
if (item.pending) { r.warnings.push({ item, reason: 'Pending item was not removed' }); continue; }
|
|
338
|
-
if (item.type === 'command') {
|
|
339
|
-
const inverse = item.uninstall ?? item.uninstallation;
|
|
340
|
-
if (!inverse) { r.warnings.push({ item, reason: 'Command has no registered uninstall; check third-party files' }); continue; }
|
|
341
|
-
const action = { ...inverse, cwd: inverse.cwd ?? item.cwd, env: { ...item.env, ...inverse.env } };
|
|
342
|
-
const prepared = command(action, home);
|
|
343
|
-
effect(item, item, null, null, context, r, item, () => run(prepared, action), 'removed');
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
checkPath(item.destination);
|
|
347
|
-
const st = exists(item.destination);
|
|
348
|
-
if (st && !st.isFile()) throw new Error('Destination is not a regular file');
|
|
349
|
-
const current = virtualFiles.has(item.destination) ? virtualFiles.get(item.destination) : st ? fs.readFileSync(item.destination) : null;
|
|
350
|
-
if (current === null) {
|
|
351
|
-
context.forget(item);
|
|
352
|
-
r.actions.push({ ...item, status: 'missing' });
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
let remaining = null;
|
|
356
|
-
if (item.type === 'json-merge') {
|
|
357
|
-
let snapshot;
|
|
358
|
-
try { snapshot = jsonMergeSnapshot(current, item); }
|
|
359
|
-
catch (e) { r.warnings.push({ item, reason: `${e.message}; retained` }); continue; }
|
|
360
|
-
if (!snapshot.count) { context.forget(item); r.actions.push({ ...item, status: 'missing' }); continue; }
|
|
361
|
-
if (snapshot.hash !== item.hash) { r.warnings.push({ item, reason: 'Modified JSON entries; retained' }); continue; }
|
|
362
|
-
remaining = mergeJson(current, item, { remove: true }).content;
|
|
363
|
-
if (item.created && Object.keys(JSON.parse(remaining.toString('utf8').replace(/^\uFEFF/, ''))).length === 0) remaining = null;
|
|
364
|
-
} else if (item.type === 'block') {
|
|
365
|
-
const text = utf8(current);
|
|
366
|
-
if (text === null) { r.warnings.push({ item, reason: 'File is not valid UTF-8; retained' }); continue; }
|
|
367
|
-
const block = blockRange(text);
|
|
368
|
-
if (!block) { context.forget(item); r.actions.push({ ...item, status: 'missing' }); continue; }
|
|
369
|
-
if (hashContent(block.content) !== item.hash) { r.warnings.push({ item, reason: 'Modified block; retained' }); continue; }
|
|
370
|
-
const remainingText = withoutBlock(text, block, item);
|
|
371
|
-
remaining = item.created && remainingText === '' ? null : Buffer.from(remainingText);
|
|
372
|
-
} else if (hashContent(current) !== item.hash) {
|
|
373
|
-
r.warnings.push({ item, reason: 'Modified file; retained' }); continue;
|
|
374
|
-
}
|
|
375
|
-
const registration = runtimeRegistration(item, context);
|
|
376
|
-
if (registration) {
|
|
377
|
-
r.warnings.push({ item, reason: `Hook registration still references runtime; retained: ${registration.destination}` });
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
effect(item, item, current, remaining, context, r, item, () => {
|
|
381
|
-
if (remaining === null) fs.unlinkSync(item.destination); else writeAtomic(item.destination, remaining);
|
|
382
|
-
}, 'removed');
|
|
383
|
-
if (dryRun) virtualFiles.set(item.destination, remaining);
|
|
384
|
-
} catch (e) { r.ok = false; r.errors.push({ item, message: e.message }); break; }
|
|
385
|
-
}
|
|
386
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { isUtf8 } from 'node:buffer';
|
|
6
|
+
import { readState, saveState, hashContent, writeAtomic, withLock, kitVersion } from './state.mjs';
|
|
7
|
+
import { mergeJson, jsonMergeSnapshot } from './json-merge.mjs';
|
|
8
|
+
|
|
9
|
+
export const START = '<!-- vibecoding-devtools:start -->';
|
|
10
|
+
export const END = '<!-- vibecoding-devtools:end -->';
|
|
11
|
+
const report = (dryRun) => ({ ok: true, dryRun, actions: [], conflicts: [], warnings: [], errors: [] });
|
|
12
|
+
const exists = (p) => {
|
|
13
|
+
try { return fs.lstatSync(p); }
|
|
14
|
+
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
15
|
+
};
|
|
16
|
+
const marker = (item) => item.type === 'block' ? item.marker ?? START : null;
|
|
17
|
+
const identity = (item) => item.type === 'command'
|
|
18
|
+
? JSON.stringify([item.type, item.cmd, item.args, item.cwd, item.cli, item.owner])
|
|
19
|
+
: JSON.stringify([item.type, path.resolve(item.destination), marker(item)]);
|
|
20
|
+
const confirmed = ({ pending, operation, previous, hashBefore, diskHash, ...item }) => item;
|
|
21
|
+
|
|
22
|
+
function checkPath(destination) {
|
|
23
|
+
for (let p = path.resolve(destination); ; p = path.dirname(p)) {
|
|
24
|
+
if (exists(p)?.isSymbolicLink()) throw new Error(`Symbolic link not allowed: ${p}`);
|
|
25
|
+
if (p === path.dirname(p)) break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function utf8(bytes) {
|
|
30
|
+
if (!isUtf8(bytes) || bytes.subarray(0, 2).equals(Buffer.from([255, 254]))
|
|
31
|
+
|| bytes.subarray(0, 2).equals(Buffer.from([254, 255]))) return null;
|
|
32
|
+
return bytes.toString('utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function blockRange(text) {
|
|
36
|
+
const start = text.indexOf(START);
|
|
37
|
+
const end = text.indexOf(END);
|
|
38
|
+
if (start < 0 && end < 0) return null;
|
|
39
|
+
if (start < 0 || end < start || text.indexOf(START, start + START.length) >= 0
|
|
40
|
+
|| text.indexOf(END, end + END.length) >= 0) throw new Error('Invalid or duplicate block markers');
|
|
41
|
+
return { start, end: end + END.length, content: text.slice(start, end + END.length) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function withoutBlock(text, block, item) {
|
|
45
|
+
let start = block.start;
|
|
46
|
+
if (item.separator && text.slice(start - item.separator.length, start) === item.separator) {
|
|
47
|
+
start -= item.separator.length;
|
|
48
|
+
}
|
|
49
|
+
return text.slice(0, start) + text.slice(block.end);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Remove only known intact blocks when validating ownership of their containing files.
|
|
53
|
+
function fileBase(current, destination, items) {
|
|
54
|
+
const record = items.find((i) => i.type === 'block' && i.destination === destination);
|
|
55
|
+
if (!record) return { bytes: current, suffix: '' };
|
|
56
|
+
const text = utf8(current);
|
|
57
|
+
if (text === null) throw new Error('Block file is not valid UTF-8');
|
|
58
|
+
const block = blockRange(text);
|
|
59
|
+
if (!block) return { bytes: current, suffix: '' };
|
|
60
|
+
if (record.pending || hashContent(block.content) !== record.hash) {
|
|
61
|
+
throw new Error('Modified or pending block; file retained');
|
|
62
|
+
}
|
|
63
|
+
return { bytes: Buffer.from(withoutBlock(text, block, record)), suffix: (record.separator ?? '') + block.content };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveCommand(cmd, options) {
|
|
67
|
+
const win = process.platform === 'win32';
|
|
68
|
+
const envValue = (name) => Object.entries(options.env).find(([k]) => k.toUpperCase() === name)?.[1];
|
|
69
|
+
const extensions = win && !path.extname(cmd)
|
|
70
|
+
? (envValue('PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : [''];
|
|
71
|
+
const directories = /[\\/]/.test(cmd) || path.isAbsolute(cmd) ? ['']
|
|
72
|
+
: (envValue('PATH') ?? '').split(path.delimiter).filter(Boolean).map((p) => p.replace(/^"(.*)"$/, '$1'));
|
|
73
|
+
for (const directory of directories) for (const ext of extensions) {
|
|
74
|
+
const file = path.resolve(options.cwd, directory, cmd + ext);
|
|
75
|
+
try {
|
|
76
|
+
if (!fs.statSync(file).isFile()) continue;
|
|
77
|
+
if (!win) fs.accessSync(file, fs.constants.X_OK);
|
|
78
|
+
return file;
|
|
79
|
+
} catch { /* try the next extension or directory */ }
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Command not found in PATH: ${cmd}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function command(action, home) {
|
|
85
|
+
if (typeof action.cmd !== 'string' || !action.cmd || !Array.isArray(action.args)
|
|
86
|
+
|| action.args.some((a) => typeof a !== 'string')) throw new Error('Command requires cmd and a string array for args');
|
|
87
|
+
const env = { ...process.env };
|
|
88
|
+
for (const [key, value] of Object.entries({ HOME: home, USERPROFILE: home, ...action.env })) {
|
|
89
|
+
if (process.platform === 'win32') for (const existing of Object.keys(env)) {
|
|
90
|
+
if (existing.toUpperCase() === key.toUpperCase()) delete env[existing];
|
|
91
|
+
}
|
|
92
|
+
env[key] = value;
|
|
93
|
+
}
|
|
94
|
+
const options = { cwd: action.cwd ?? home, env, encoding: 'utf8', shell: false, windowsHide: true };
|
|
95
|
+
let cmd = resolveCommand(action.cmd, options);
|
|
96
|
+
let args = action.args;
|
|
97
|
+
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd)) {
|
|
98
|
+
if ([cmd, ...args].some((a) => /["%!\r\n\0]/.test(a))) throw new Error('Unsafe argument for cmd.exe');
|
|
99
|
+
args = ['/d', '/s', '/c', `"${[cmd, ...args].map((a) => `"${a}"`).join(' ')}"`];
|
|
100
|
+
cmd = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe');
|
|
101
|
+
options.windowsVerbatimArguments = true;
|
|
102
|
+
}
|
|
103
|
+
return { cmd, args, options };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function run(prepared, action) {
|
|
107
|
+
const { cmd, args, options } = prepared;
|
|
108
|
+
const r = spawnSync(cmd, args, options);
|
|
109
|
+
if (r.error) throw r.error;
|
|
110
|
+
if (r.status !== 0) throw new Error(`Command failed (${r.status ?? r.signal}): ${action.cmd}\n${r.stderr ?? ''}`);
|
|
111
|
+
return { stdout: r.stdout, stderr: r.stderr };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function session(home, dryRun, r, work) {
|
|
115
|
+
try {
|
|
116
|
+
return withLock(home, () => {
|
|
117
|
+
const state = readState({ home });
|
|
118
|
+
const persist = () => { if (!dryRun) saveState(state, { home }); };
|
|
119
|
+
const save = (item) => {
|
|
120
|
+
const i = state.items.findIndex((x) => identity(x) === identity(item));
|
|
121
|
+
if (i < 0) state.items.push(item); else state.items[i] = item;
|
|
122
|
+
persist();
|
|
123
|
+
};
|
|
124
|
+
const forget = (item) => {
|
|
125
|
+
const i = state.items.findIndex((x) => identity(x) === identity(item));
|
|
126
|
+
if (i >= 0) state.items.splice(i, 1);
|
|
127
|
+
persist();
|
|
128
|
+
};
|
|
129
|
+
// Never repeat pending commands: disk hashes cannot prove external process effects.
|
|
130
|
+
for (const item of [...state.items]) if (item.pending) {
|
|
131
|
+
if (item.type === 'command') {
|
|
132
|
+
r.warnings.push({ item, reason: 'Pending command: check the third party before retrying or uninstalling' });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
checkPath(item.destination);
|
|
136
|
+
const st = exists(item.destination);
|
|
137
|
+
const current = st?.isFile() ? fs.readFileSync(item.destination) : null;
|
|
138
|
+
const hash = current ? hashContent(current) : null;
|
|
139
|
+
if ((!st || st.isFile()) && hash === item.diskHash) {
|
|
140
|
+
if (item.operation === 'remove') forget(item); else save(confirmed(item));
|
|
141
|
+
} else if ((!st || st.isFile()) && hash === item.hashBefore) {
|
|
142
|
+
if (item.previous) save(item.previous); else forget(item);
|
|
143
|
+
} else r.warnings.push({ item, reason: 'Pending item has unexpected content; retained for review' });
|
|
144
|
+
}
|
|
145
|
+
work({ state, save, forget, persist, virtualFiles: new Map() });
|
|
146
|
+
return r;
|
|
147
|
+
}, { dryRun });
|
|
148
|
+
} catch (e) {
|
|
149
|
+
r.ok = false;
|
|
150
|
+
r.errors.push({ message: e.message });
|
|
151
|
+
return r;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function effect(item, previous, current, content, context, r, action, apply, status = 'executed') {
|
|
156
|
+
const pending = { ...item, pending: true, operation: status === 'removed' ? 'remove' : 'install',
|
|
157
|
+
previous: previous ?? null, hashBefore: current === null ? null : hashContent(current),
|
|
158
|
+
diskHash: content === null ? null : hashContent(content) };
|
|
159
|
+
context.save(pending);
|
|
160
|
+
const record = { ...action, status: r.dryRun ? 'planned' : 'pending' };
|
|
161
|
+
r.actions.push(record);
|
|
162
|
+
if (!r.dryRun) apply();
|
|
163
|
+
if (status === 'removed') context.forget(item); else context.save(item);
|
|
164
|
+
record.status = r.dryRun ? 'planned' : status;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Return { ok, dryRun, actions, conflicts, warnings, errors }; skip identical items and conflicts.
|
|
168
|
+
export function execute(plan, { dryRun = false, force = false, home = os.homedir(), choices } = {}) {
|
|
169
|
+
const r = report(dryRun);
|
|
170
|
+
return session(home, dryRun, r, (context) => {
|
|
171
|
+
const { state } = context;
|
|
172
|
+
if (choices && (state.version !== kitVersion || JSON.stringify(state.choices) !== JSON.stringify(choices))) {
|
|
173
|
+
state.choices = structuredClone(choices);
|
|
174
|
+
context.persist();
|
|
175
|
+
}
|
|
176
|
+
const conflict = (action, reason) => { r.conflicts.push({ ...action, reason }); r.ok = false; };
|
|
177
|
+
const removals = plan.filter((action) => action.operation === 'remove');
|
|
178
|
+
if (removals.some((action) => action.owner !== 'kit' || action.type === 'command')) {
|
|
179
|
+
throw new Error('Update removes only kit-owned filesystem items');
|
|
180
|
+
}
|
|
181
|
+
const obsolete = removals.map((action) => state.items.find((i) => identity(i) === identity(action)))
|
|
182
|
+
.filter((item) => item?.owner === 'kit').map((item) => ({ ...item, operation: 'remove', situation: 'remove' }));
|
|
183
|
+
const registrations = obsolete.filter((item) => item.type === 'json-merge');
|
|
184
|
+
const files = obsolete.filter((item) => item.type !== 'json-merge');
|
|
185
|
+
removeItems(context, r, registrations, home);
|
|
186
|
+
if (!r.ok) return;
|
|
187
|
+
const apply = (action) => {
|
|
188
|
+
if (!['kit', 'ecc', 'impeccable'].includes(action.owner) || !action.cli) throw new Error('Action requires owner and cli');
|
|
189
|
+
if (action.operation === 'remove') {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (action.situation === 'same') { r.actions.push({ ...action, status: 'same' }); return; }
|
|
193
|
+
if (action.situation === 'conflict' && !force) { conflict(action, 'Conflict identified in plan'); return; }
|
|
194
|
+
if (action.type === 'command') {
|
|
195
|
+
const item = { ...action, cwd: action.cwd ?? home };
|
|
196
|
+
const previous = state.items.find((i) => identity(i) === identity(item));
|
|
197
|
+
if (previous?.pending) { conflict(action, 'Pending command; check effects before retrying'); return; }
|
|
198
|
+
if (previous && previous.version === item.version
|
|
199
|
+
&& JSON.stringify(previous.env ?? {}) === JSON.stringify(item.env ?? {})) {
|
|
200
|
+
r.actions.push({ ...action, status: 'same' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const prepared = command(action, home);
|
|
204
|
+
effect(item, previous, null, null, context, r, action, () => {
|
|
205
|
+
Object.assign(r.actions.at(-1), run(prepared, action));
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!['copy', 'write', 'block', 'json-merge'].includes(action.type)) throw new Error(`Unknown type: ${action.type}`);
|
|
210
|
+
if (!['copy', 'json-merge'].includes(action.type) && typeof action.content !== 'string'
|
|
211
|
+
&& !(action.type === 'write' && Buffer.isBuffer(action.content))) throw new Error('Action requires text content');
|
|
212
|
+
const destination = path.resolve(action.destination);
|
|
213
|
+
checkPath(destination);
|
|
214
|
+
if (action.type === 'copy') {
|
|
215
|
+
const st = fs.lstatSync(action.source);
|
|
216
|
+
if (st.isSymbolicLink()) throw new Error(`Source is a symbolic link: ${action.source}`);
|
|
217
|
+
if (st.isDirectory()) {
|
|
218
|
+
const relative = path.relative(path.resolve(action.source), destination);
|
|
219
|
+
if (!relative || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
|
|
220
|
+
throw new Error('Copy destination cannot be inside source');
|
|
221
|
+
}
|
|
222
|
+
if (exists(destination) && !exists(destination).isDirectory()) { conflict(action, 'Destination is not a directory'); return; }
|
|
223
|
+
if (!dryRun) fs.mkdirSync(destination, { recursive: true });
|
|
224
|
+
r.actions.push({ ...action, destination, status: dryRun ? 'planned' : 'executed' });
|
|
225
|
+
for (const name of fs.readdirSync(action.source).sort()) apply({ ...action, situation: undefined,
|
|
226
|
+
source: path.join(action.source, name), destination: path.join(destination, name) });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!st.isFile()) throw new Error('Source is not a regular file');
|
|
230
|
+
}
|
|
231
|
+
const st = exists(destination);
|
|
232
|
+
if (st && !st.isFile()) { conflict(action, 'Destination is not a regular file'); return; }
|
|
233
|
+
const key = { ...action, destination };
|
|
234
|
+
const previous = state.items.find((i) => identity(i) === identity(key));
|
|
235
|
+
if (previous?.pending) { conflict(action, 'Pending item; unexpected content'); return; }
|
|
236
|
+
const current = st ? fs.readFileSync(destination) : null;
|
|
237
|
+
let content;
|
|
238
|
+
let extra;
|
|
239
|
+
if (action.type === 'json-merge') {
|
|
240
|
+
let merged;
|
|
241
|
+
try { merged = mergeJson(current, action); }
|
|
242
|
+
catch (e) { conflict(action, e.message); return; }
|
|
243
|
+
if (merged.before.hash === merged.after.hash) { r.actions.push({ ...action, status: 'same' }); return; }
|
|
244
|
+
if (merged.before.count && !force && (!previous || merged.before.hash !== previous.hash)) {
|
|
245
|
+
conflict(action, 'Preexisting or modified JSON entries'); return;
|
|
246
|
+
}
|
|
247
|
+
content = merged.content;
|
|
248
|
+
extra = { hash: merged.after.hash, format: action.format, scriptPaths: action.scriptPaths,
|
|
249
|
+
scriptReferences: action.scriptReferences,
|
|
250
|
+
created: previous?.created ?? !st, createdPaths: previous?.createdPaths ?? merged.createdPaths };
|
|
251
|
+
} else if (action.type === 'block') {
|
|
252
|
+
const text = current ? utf8(current) : '';
|
|
253
|
+
if (text === null) { conflict(action, 'File is not valid UTF-8'); return; }
|
|
254
|
+
const block = blockRange(text);
|
|
255
|
+
const replacement = `${START}\n${action.content}\n${END}`;
|
|
256
|
+
if (blockRange(replacement)?.content !== replacement) throw new Error('Content contains reserved markers');
|
|
257
|
+
if (block?.content === replacement) { r.actions.push({ ...action, status: 'same' }); return; }
|
|
258
|
+
if (block && !force && (!previous || hashContent(block.content) !== previous.hash)) {
|
|
259
|
+
conflict(action, 'Preexisting or modified block'); return;
|
|
260
|
+
}
|
|
261
|
+
const separator = text && !text.endsWith('\n') ? '\n' : '';
|
|
262
|
+
content = block ? text.slice(0, block.start) + replacement + text.slice(block.end) : text + separator + replacement;
|
|
263
|
+
extra = { hash: hashContent(replacement), marker: START,
|
|
264
|
+
separator: block ? previous?.separator ?? '' : separator, created: previous?.created ?? !st };
|
|
265
|
+
} else {
|
|
266
|
+
const expected = action.type === 'copy' ? fs.readFileSync(action.source) : Buffer.from(action.content);
|
|
267
|
+
let base;
|
|
268
|
+
try { base = current ? fileBase(current, destination, state.items) : { bytes: null, suffix: '' }; }
|
|
269
|
+
catch (e) { conflict(action, e.message); return; }
|
|
270
|
+
if (base.bytes?.equals(expected)) { r.actions.push({ ...action, status: 'same' }); return; }
|
|
271
|
+
if (st && !force && (!previous || hashContent(base.bytes) !== previous.hash)) {
|
|
272
|
+
conflict(action, 'Preexisting or modified file'); return;
|
|
273
|
+
}
|
|
274
|
+
content = Buffer.concat([expected, Buffer.from(base.suffix)]);
|
|
275
|
+
extra = { hash: hashContent(expected) };
|
|
276
|
+
}
|
|
277
|
+
const item = { type: action.type, destination, owner: action.owner, cli: action.cli, ...extra };
|
|
278
|
+
effect(item, previous, current, content, context, r, { ...action, destination },
|
|
279
|
+
() => writeAtomic(destination, content));
|
|
280
|
+
if (dryRun) context.virtualFiles.set(destination, content);
|
|
281
|
+
};
|
|
282
|
+
for (const action of plan) {
|
|
283
|
+
try { apply(action); }
|
|
284
|
+
catch (e) { r.ok = false; r.errors.push({ action, message: e.message }); break; }
|
|
285
|
+
}
|
|
286
|
+
// Retarget registrations only after installing their replacement runtime.
|
|
287
|
+
// Cleanup must see the resulting registrations, including retained conflicts.
|
|
288
|
+
if (r.ok) removeItems(context, r, files, home);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Remove blocks before files and revalidate pending items before any effects.
|
|
293
|
+
export function uninstall({ home = os.homedir(), dryRun = false } = {}) {
|
|
294
|
+
const r = report(dryRun);
|
|
295
|
+
return session(home, dryRun, r, (context) => {
|
|
296
|
+
removeItems(context, r, context.state.items, home);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function runtimeRegistration(item, context) {
|
|
301
|
+
if (!['copy', 'write'].includes(item.type)) return null;
|
|
302
|
+
const destination = path.resolve(item.destination);
|
|
303
|
+
for (const registration of context.state.items.filter((entry) => entry.type === 'json-merge')) {
|
|
304
|
+
const paths = [...new Set([...(registration.scriptPaths ?? []),
|
|
305
|
+
...(registration.previous?.scriptPaths ?? [])])].filter((script) => {
|
|
306
|
+
const runtime = path.dirname(path.resolve(script));
|
|
307
|
+
return destination === path.resolve(script) || destination.startsWith(path.join(runtime, 'lib') + path.sep);
|
|
308
|
+
});
|
|
309
|
+
if (!paths.length) continue;
|
|
310
|
+
if (registration.pending) return registration;
|
|
311
|
+
try {
|
|
312
|
+
checkPath(registration.destination);
|
|
313
|
+
const current = context.virtualFiles.has(registration.destination)
|
|
314
|
+
? context.virtualFiles.get(registration.destination)
|
|
315
|
+
: exists(registration.destination) ? fs.readFileSync(registration.destination) : null;
|
|
316
|
+
if (current === null) continue;
|
|
317
|
+
// scriptPaths also contains legacy paths used for ownership detection.
|
|
318
|
+
// Inspect the actual commands so a successful retarget releases those paths.
|
|
319
|
+
if (jsonMergeSnapshot(current, {
|
|
320
|
+
...registration, scriptPaths: paths,
|
|
321
|
+
scriptReferences: (registration.scriptReferences ?? []).filter((reference) =>
|
|
322
|
+
paths.some((script) => path.resolve(script) === path.resolve(path.dirname(registration.destination),
|
|
323
|
+
'..', reference.replace(/^\$\{CLAUDE_PROJECT_DIR\}\//, '')))),
|
|
324
|
+
}).count) return registration;
|
|
325
|
+
} catch { return registration; } // Unreadable or invalid registrations remain dependencies.
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function removeItems(context, r, items, home) {
|
|
331
|
+
const dryRun = r.dryRun;
|
|
332
|
+
const isRegistration = (item) => ['block', 'json-merge'].includes(item.type);
|
|
333
|
+
const order = [...items].reverse().sort((a, b) => Number(isRegistration(b)) - Number(isRegistration(a)));
|
|
334
|
+
const { virtualFiles } = context;
|
|
335
|
+
for (const item of order) {
|
|
336
|
+
try {
|
|
337
|
+
if (item.pending) { r.warnings.push({ item, reason: 'Pending item was not removed' }); continue; }
|
|
338
|
+
if (item.type === 'command') {
|
|
339
|
+
const inverse = item.uninstall ?? item.uninstallation;
|
|
340
|
+
if (!inverse) { r.warnings.push({ item, reason: 'Command has no registered uninstall; check third-party files' }); continue; }
|
|
341
|
+
const action = { ...inverse, cwd: inverse.cwd ?? item.cwd, env: { ...item.env, ...inverse.env } };
|
|
342
|
+
const prepared = command(action, home);
|
|
343
|
+
effect(item, item, null, null, context, r, item, () => run(prepared, action), 'removed');
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
checkPath(item.destination);
|
|
347
|
+
const st = exists(item.destination);
|
|
348
|
+
if (st && !st.isFile()) throw new Error('Destination is not a regular file');
|
|
349
|
+
const current = virtualFiles.has(item.destination) ? virtualFiles.get(item.destination) : st ? fs.readFileSync(item.destination) : null;
|
|
350
|
+
if (current === null) {
|
|
351
|
+
context.forget(item);
|
|
352
|
+
r.actions.push({ ...item, status: 'missing' });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
let remaining = null;
|
|
356
|
+
if (item.type === 'json-merge') {
|
|
357
|
+
let snapshot;
|
|
358
|
+
try { snapshot = jsonMergeSnapshot(current, item); }
|
|
359
|
+
catch (e) { r.warnings.push({ item, reason: `${e.message}; retained` }); continue; }
|
|
360
|
+
if (!snapshot.count) { context.forget(item); r.actions.push({ ...item, status: 'missing' }); continue; }
|
|
361
|
+
if (snapshot.hash !== item.hash) { r.warnings.push({ item, reason: 'Modified JSON entries; retained' }); continue; }
|
|
362
|
+
remaining = mergeJson(current, item, { remove: true }).content;
|
|
363
|
+
if (item.created && Object.keys(JSON.parse(remaining.toString('utf8').replace(/^\uFEFF/, ''))).length === 0) remaining = null;
|
|
364
|
+
} else if (item.type === 'block') {
|
|
365
|
+
const text = utf8(current);
|
|
366
|
+
if (text === null) { r.warnings.push({ item, reason: 'File is not valid UTF-8; retained' }); continue; }
|
|
367
|
+
const block = blockRange(text);
|
|
368
|
+
if (!block) { context.forget(item); r.actions.push({ ...item, status: 'missing' }); continue; }
|
|
369
|
+
if (hashContent(block.content) !== item.hash) { r.warnings.push({ item, reason: 'Modified block; retained' }); continue; }
|
|
370
|
+
const remainingText = withoutBlock(text, block, item);
|
|
371
|
+
remaining = item.created && remainingText === '' ? null : Buffer.from(remainingText);
|
|
372
|
+
} else if (hashContent(current) !== item.hash) {
|
|
373
|
+
r.warnings.push({ item, reason: 'Modified file; retained' }); continue;
|
|
374
|
+
}
|
|
375
|
+
const registration = runtimeRegistration(item, context);
|
|
376
|
+
if (registration) {
|
|
377
|
+
r.warnings.push({ item, reason: `Hook registration still references runtime; retained: ${registration.destination}` });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
effect(item, item, current, remaining, context, r, item, () => {
|
|
381
|
+
if (remaining === null) fs.unlinkSync(item.destination); else writeAtomic(item.destination, remaining);
|
|
382
|
+
}, 'removed');
|
|
383
|
+
if (dryRun) virtualFiles.set(item.destination, remaining);
|
|
384
|
+
} catch (e) { r.ok = false; r.errors.push({ item, message: e.message }); break; }
|
|
385
|
+
}
|
|
386
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibecoding-devtools",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "vibecoding-devtools",
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clack/prompts": "1.8.1"
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibecoding-devtools",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Rules, skills, hooks and templates for AI development with Claude Code, Codex and Antigravity.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/Or4cu1o/vibecoding-devtools"
|
|
8
|
+
"url": "git+https://github.com/Or4cu1o/vibecoding-devtools.git"
|
|
9
9
|
},
|
|
10
10
|
"homepage": "https://github.com/Or4cu1o/vibecoding-devtools#readme",
|
|
11
11
|
"bugs": {
|