codz-cli 0.0.0-stage → 0.1.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/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +525 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +638 -0
- package/dist/skills/createDemo.js +1026 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The project map is a cache rebuilt from the code. It lives in .codz/
|
|
2
|
+
// (git-ignored) and is refreshed before every skill when files changed.
|
|
3
|
+
import { mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { hash, readJson, readText, walk } from "./fsutil.js";
|
|
7
|
+
import { MAP_SCHEMA_VERSION } from "./types.js";
|
|
8
|
+
export const TOOL_DIR = '.codz';
|
|
9
|
+
const FINGERPRINT_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.dart', '.yaml', '.yml', '.arb', '.env', '.example', '.local'];
|
|
10
|
+
/**
|
|
11
|
+
* codz's own source, so a map scanned by an older codz is rebuilt after an
|
|
12
|
+
* upgrade even when the version number did not change.
|
|
13
|
+
*/
|
|
14
|
+
let toolBuild;
|
|
15
|
+
function toolFingerprint() {
|
|
16
|
+
if (toolBuild)
|
|
17
|
+
return toolBuild;
|
|
18
|
+
const src = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
// src/*.ts when run from source, dist/*.js when installed from npm.
|
|
20
|
+
const parts = walk(src, '.', { exts: import.meta.url.endsWith('.ts') ? ['.ts'] : ['.js'] }).map((f) => {
|
|
21
|
+
const st = statSync(join(src, f));
|
|
22
|
+
return `${f}:${st.size}:${Math.floor(st.mtimeMs)}`;
|
|
23
|
+
});
|
|
24
|
+
return (toolBuild = hash(parts.join('\n')));
|
|
25
|
+
}
|
|
26
|
+
/** Read-only runs (--dry-run, doctor) scan in memory and leave .codz/ alone. */
|
|
27
|
+
let cacheWrites = true;
|
|
28
|
+
export function setCacheWrites(on) {
|
|
29
|
+
cacheWrites = on;
|
|
30
|
+
}
|
|
31
|
+
export function toolDir(root, ...parts) {
|
|
32
|
+
const dir = join(root, TOOL_DIR, ...parts);
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
/** Cheap fingerprint of the project: path, size and modified time of relevant files. */
|
|
37
|
+
export function fingerprint(root, configText) {
|
|
38
|
+
const files = walk(root, '.', { exts: FINGERPRINT_EXTS });
|
|
39
|
+
const parts = [configText];
|
|
40
|
+
for (const f of files) {
|
|
41
|
+
try {
|
|
42
|
+
const st = statSync(join(root, f));
|
|
43
|
+
parts.push(`${f}:${st.size}:${Math.floor(st.mtimeMs)}`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// File vanished during the walk; skip it.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return hash(parts.join('\n'));
|
|
50
|
+
}
|
|
51
|
+
export async function loadMap(root, adapter, conventions, opts = {}) {
|
|
52
|
+
const cachePath = join(root, TOOL_DIR, 'cache', 'map.json');
|
|
53
|
+
const configText = readText(join(root, 'codz.config.yaml')) ?? '';
|
|
54
|
+
const fp = fingerprint(root, configText);
|
|
55
|
+
const cached = readJson(cachePath);
|
|
56
|
+
if (!opts.force && cached && cached.schema === MAP_SCHEMA_VERSION && cached.adapter === `${adapter.id}@${adapter.version}` && cached.tool === toolFingerprint() && cached.fingerprint === fp) {
|
|
57
|
+
return { map: cached.map, fromCache: true };
|
|
58
|
+
}
|
|
59
|
+
// An old schema or adapter version is simply rebuilt, never migrated.
|
|
60
|
+
const map = await adapter.scan(root, conventions);
|
|
61
|
+
const file = { schema: MAP_SCHEMA_VERSION, adapter: `${adapter.id}@${adapter.version}`, tool: toolFingerprint(), fingerprint: fp, map };
|
|
62
|
+
if (cacheWrites) {
|
|
63
|
+
toolDir(root, 'cache');
|
|
64
|
+
writeFileSync(cachePath, JSON.stringify(file, null, 2));
|
|
65
|
+
}
|
|
66
|
+
return { map, fromCache: false };
|
|
67
|
+
}
|
|
68
|
+
export function invalidateMap(root) {
|
|
69
|
+
const cachePath = join(root, TOOL_DIR, 'cache', 'map.json');
|
|
70
|
+
try {
|
|
71
|
+
writeFileSync(cachePath, '{}');
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// No cache yet.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// Change sets: plan, preview, apply (with hash checks, atomic writes,
|
|
2
|
+
// backups, formatting and verification) and undo.
|
|
3
|
+
import { fileHeader } from "./templates.js";
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
|
+
import { invalidateMap, toolDir, TOOL_DIR } from "./cache.js";
|
|
8
|
+
import { diffStats, unifiedDiff } from "./diff.js";
|
|
9
|
+
import { exists, hash, readJson, readText } from "./fsutil.js";
|
|
10
|
+
import { dirtyFiles, isGitRepo } from "./git.js";
|
|
11
|
+
import { ToolError } from "./types.js";
|
|
12
|
+
import { runTool, toolMissing } from "./proc.js";
|
|
13
|
+
export function newId() {
|
|
14
|
+
const d = new Date();
|
|
15
|
+
const stamp = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}-${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}`;
|
|
16
|
+
return `${stamp}-${randomBytes(3).toString('hex')}`;
|
|
17
|
+
}
|
|
18
|
+
function safePath(root, relPath) {
|
|
19
|
+
const abs = resolve(root, relPath);
|
|
20
|
+
const r = resolve(root);
|
|
21
|
+
if (abs !== r && !abs.startsWith(r + '/') && !abs.startsWith(r + '\\')) {
|
|
22
|
+
throw new ToolError('outside_root', `Refusing to touch ${relPath}: it is outside the project root.`);
|
|
23
|
+
}
|
|
24
|
+
if (relPath.split(/[\\/]/).includes(TOOL_DIR))
|
|
25
|
+
throw new ToolError('outside_root', `Refusing to edit codz's own folder (${relPath}).`);
|
|
26
|
+
// Lexical checks are not enough: a symbolic link inside the project (for example lib -> ~/.ssh) would carry the
|
|
27
|
+
// write outside it. Resolve the deepest folder that exists and check that it is still inside the real root.
|
|
28
|
+
const realRoot = realpathSync(r);
|
|
29
|
+
let probe = abs;
|
|
30
|
+
while (!existsSync(probe) && dirname(probe) !== probe)
|
|
31
|
+
probe = dirname(probe);
|
|
32
|
+
if (existsSync(abs) && lstatSync(abs).isSymbolicLink())
|
|
33
|
+
throw new ToolError('outside_root', `Refusing to touch ${relPath}: it is a symbolic link.`, 'Replace the link with a real file, or run codz on the folder it points to.');
|
|
34
|
+
const realProbe = realpathSync(probe);
|
|
35
|
+
if (realProbe !== realRoot && !realProbe.startsWith(realRoot + sep)) {
|
|
36
|
+
throw new ToolError('outside_root', `Refusing to touch ${relPath}: a symbolic link takes it outside the project root.`);
|
|
37
|
+
}
|
|
38
|
+
return abs;
|
|
39
|
+
}
|
|
40
|
+
export function buildChangeSet(root, skill, edits) {
|
|
41
|
+
const changes = [];
|
|
42
|
+
const entries = [...edits.files.entries(), ...[...(edits.deletes ?? [])].filter((p) => !edits.files.has(p)).map((p) => [p, null])];
|
|
43
|
+
for (let [path, after] of entries.sort(([a], [b]) => a.localeCompare(b))) {
|
|
44
|
+
const abs = safePath(root, path);
|
|
45
|
+
const before = readText(abs);
|
|
46
|
+
if (before === after)
|
|
47
|
+
continue; // also skips deleting a file that does not exist
|
|
48
|
+
// New files start with the project's header template, if it has one.
|
|
49
|
+
if (before === null && after !== null) {
|
|
50
|
+
const header = fileHeader(root, path);
|
|
51
|
+
if (header && !after.startsWith(header))
|
|
52
|
+
after = header + after;
|
|
53
|
+
}
|
|
54
|
+
changes.push({ path, baseHash: before === null ? null : hash(before), before, after });
|
|
55
|
+
}
|
|
56
|
+
const rewrites = (edits.rewrites ?? []).filter((p) => changes.some((c) => c.path === p));
|
|
57
|
+
return { id: newId(), skill, summary: edits.summary, warnings: edits.warnings, changes, generated: edits.generated, ...(rewrites.length ? { rewrites } : {}) };
|
|
58
|
+
}
|
|
59
|
+
/** Lines that say something (not blank, not a lone bracket), trimmed. */
|
|
60
|
+
const meaningful = (text) => text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 3);
|
|
61
|
+
/**
|
|
62
|
+
* Files an edit would mostly replace: an existing file where less than half of
|
|
63
|
+
* its lines survive. Commands edit existing files at known places (an import,
|
|
64
|
+
* a route, a dependency) and keep the rest, so this means a generator wrote a
|
|
65
|
+
* whole new file over one it should only have edited (as when a login page once
|
|
66
|
+
* replaced a GetX binding). Files a command replaces on purpose are listed in
|
|
67
|
+
* cs.rewrites and allowed.
|
|
68
|
+
*/
|
|
69
|
+
export function rewriteProblems(cs) {
|
|
70
|
+
const allowed = new Set(cs.rewrites ?? []);
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const c of cs.changes) {
|
|
73
|
+
if (c.before === null || c.after === null || allowed.has(c.path))
|
|
74
|
+
continue;
|
|
75
|
+
const before = meaningful(c.before);
|
|
76
|
+
if (before.length < 4)
|
|
77
|
+
continue;
|
|
78
|
+
const after = new Set(meaningful(c.after));
|
|
79
|
+
const kept = before.filter((l) => after.has(l)).length;
|
|
80
|
+
if (kept / before.length < 0.5)
|
|
81
|
+
out.push(c.path);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
export function renderDiff(cs, color) {
|
|
86
|
+
return cs.changes.map((c) => unifiedDiff(c.path, c.before, c.after, { color })).join('\n');
|
|
87
|
+
}
|
|
88
|
+
export function changeSetJson(cs, includeDiff = true) {
|
|
89
|
+
return {
|
|
90
|
+
id: cs.id,
|
|
91
|
+
skill: cs.skill,
|
|
92
|
+
summary: cs.summary,
|
|
93
|
+
warnings: cs.warnings,
|
|
94
|
+
files: cs.changes.map((c) => ({
|
|
95
|
+
path: c.path,
|
|
96
|
+
action: c.before === null ? 'create' : c.after === null ? 'delete' : 'modify',
|
|
97
|
+
...diffStats(c.before, c.after),
|
|
98
|
+
...(includeDiff ? { diff: unifiedDiff(c.path, c.before, c.after) } : {}),
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ---------- Plans (preview now, apply exactly this later) ----------
|
|
103
|
+
export function savePlan(root, cs) {
|
|
104
|
+
const dir = toolDir(root, 'plans');
|
|
105
|
+
writeFileSync(join(dir, `${cs.id}.json`), JSON.stringify(cs, null, 2));
|
|
106
|
+
return cs.id;
|
|
107
|
+
}
|
|
108
|
+
export function loadPlan(root, id) {
|
|
109
|
+
const cs = readJson(join(root, TOOL_DIR, 'plans', `${id}.json`));
|
|
110
|
+
if (!cs)
|
|
111
|
+
throw new ToolError('no_plan', `No saved plan with id "${id}".`, 'Run the skill again with --plan to create one.');
|
|
112
|
+
return cs;
|
|
113
|
+
}
|
|
114
|
+
function historyPath(root) {
|
|
115
|
+
return join(toolDir(root), 'history.json');
|
|
116
|
+
}
|
|
117
|
+
export function readHistory(root) {
|
|
118
|
+
return readJson(historyPath(root)) ?? [];
|
|
119
|
+
}
|
|
120
|
+
function writeHistory(root, entries) {
|
|
121
|
+
writeFileSync(historyPath(root), JSON.stringify(entries, null, 2));
|
|
122
|
+
}
|
|
123
|
+
/** Records a change made outside applyChangeSet (codz new writes a whole project). */
|
|
124
|
+
export function appendHistory(root, entry) {
|
|
125
|
+
writeHistory(root, [...readHistory(root), entry]);
|
|
126
|
+
}
|
|
127
|
+
/** Marks a history entry as undone. */
|
|
128
|
+
export function markUndone(root, id) {
|
|
129
|
+
const h = readHistory(root);
|
|
130
|
+
const e = h.find((x) => x.id === id);
|
|
131
|
+
if (e) {
|
|
132
|
+
e.undone = true;
|
|
133
|
+
writeHistory(root, h);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function run(cmd, args, cwd) {
|
|
137
|
+
const r = runTool(cmd, args, { cwd, timeout: 180_000 });
|
|
138
|
+
if (toolMissing(r))
|
|
139
|
+
return { ok: false, output: '', missing: true };
|
|
140
|
+
return { ok: r.status === 0, output: `${r.stdout ?? ''}${r.stderr ?? ''}`, missing: false };
|
|
141
|
+
}
|
|
142
|
+
/** Counts analyzer error lines per changed file. */
|
|
143
|
+
function errorCounts(output, files) {
|
|
144
|
+
const counts = new Map();
|
|
145
|
+
for (const line of output.split('\n')) {
|
|
146
|
+
if (!/error/i.test(line))
|
|
147
|
+
continue;
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
if (line.includes(f))
|
|
150
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return counts;
|
|
154
|
+
}
|
|
155
|
+
/** A lock older than this is from a run that died; it is taken over. */
|
|
156
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
157
|
+
/**
|
|
158
|
+
* Runs fn holding .codz/lock, so two codz runs on one project (a person and
|
|
159
|
+
* an AI agent, two terminals) cannot apply or undo at the same time and mix their
|
|
160
|
+
* files and backups. A lock left by a process that is gone, or an old one, is taken over.
|
|
161
|
+
*/
|
|
162
|
+
export function withProjectLock(root, fn) {
|
|
163
|
+
const dir = join(root, TOOL_DIR);
|
|
164
|
+
mkdirSync(dir, { recursive: true });
|
|
165
|
+
const file = join(dir, 'lock');
|
|
166
|
+
for (let attempt = 0;; attempt++) {
|
|
167
|
+
try {
|
|
168
|
+
const fd = openSync(file, 'wx');
|
|
169
|
+
writeSync(fd, JSON.stringify({ pid: process.pid, started: new Date().toISOString() }));
|
|
170
|
+
closeSync(fd);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
if (e.code !== 'EEXIST' || attempt > 0) {
|
|
175
|
+
if (e.code === 'EEXIST')
|
|
176
|
+
throw new ToolError('busy', 'Another codz change is being applied or undone in this project.', 'Wait for it to finish and try again.');
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
let held = {};
|
|
180
|
+
try {
|
|
181
|
+
held = JSON.parse(readFileSync(file, 'utf8'));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
held = {};
|
|
185
|
+
}
|
|
186
|
+
const alive = (() => {
|
|
187
|
+
if (!held.pid || held.pid === process.pid)
|
|
188
|
+
return held.pid === process.pid;
|
|
189
|
+
try {
|
|
190
|
+
process.kill(held.pid, 0);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
return err.code === 'EPERM';
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
const old = !held.started || Date.now() - Date.parse(held.started) > LOCK_STALE_MS;
|
|
198
|
+
if (alive && !old)
|
|
199
|
+
throw new ToolError('busy', `Another codz run (process ${held.pid}) is applying or undoing a change in this project.`, 'Wait for it to finish and try again.');
|
|
200
|
+
rmSync(file, { force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return fn();
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
rmSync(file, { force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function applyChangeSet(root, cs, opts = {}) {
|
|
211
|
+
if (cs.changes.length === 0)
|
|
212
|
+
return { files: [], formatted: false, verified: false };
|
|
213
|
+
return withProjectLock(root, () => applyLocked(root, cs, opts));
|
|
214
|
+
}
|
|
215
|
+
function applyLocked(root, cs, opts) {
|
|
216
|
+
if (!opts.allowDirty && isGitRepo(root)) {
|
|
217
|
+
const dirty = dirtyFiles(root).filter((f) => f !== 'codz.config.yaml');
|
|
218
|
+
if (dirty.length) {
|
|
219
|
+
throw new ToolError('dirty_git', `The working tree has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ', ...' : ''}).`, 'Commit or stash them first, or pass --allow-dirty. Undo still works either way, from codz backups.');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// A generator must not write a whole new file over one it should only edit.
|
|
223
|
+
const replaced = rewriteProblems(cs);
|
|
224
|
+
if (replaced.length && !opts.allowRewrite) {
|
|
225
|
+
throw new ToolError('unsafe_rewrite', `This change would replace most of ${replaced.join(', ')} instead of editing ${replaced.length === 1 ? 'it' : 'them'}, so nothing was applied.`, 'This is a codz bug: please report it with the command you ran. Check the preview with --dry-run.');
|
|
226
|
+
}
|
|
227
|
+
// Every target must be exactly as it was when the change set was planned.
|
|
228
|
+
for (const c of cs.changes) {
|
|
229
|
+
const current = readText(safePath(root, c.path));
|
|
230
|
+
const currentHash = current === null ? null : hash(current);
|
|
231
|
+
if (currentHash !== c.baseHash) {
|
|
232
|
+
throw new ToolError('changed_since_plan', `${c.path} changed after the preview was made, so nothing was applied.`, 'Run the skill again to get a fresh preview.');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const files = cs.changes.filter((c) => c.after !== null).map((c) => c.path);
|
|
236
|
+
const verifyCmd = opts.verify !== false ? opts.adapter?.verifyCommand?.(root, files) : null;
|
|
237
|
+
let baseline = new Map();
|
|
238
|
+
if (verifyCmd) {
|
|
239
|
+
const existing = cs.changes.filter((c) => c.before !== null).map((c) => c.path);
|
|
240
|
+
if (existing.length) {
|
|
241
|
+
const before = run(verifyCmd.cmd, verifyCmd.args, root);
|
|
242
|
+
if (!before.missing)
|
|
243
|
+
baseline = errorCounts(before.output, files);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Back up originals.
|
|
247
|
+
const backupDir = toolDir(root, 'backups', cs.id);
|
|
248
|
+
for (const c of cs.changes) {
|
|
249
|
+
if (c.before !== null) {
|
|
250
|
+
const dest = join(backupDir, c.path);
|
|
251
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
252
|
+
writeFileSync(dest, c.before);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Write all files to temporary names first, then swap them in.
|
|
256
|
+
const temps = [];
|
|
257
|
+
try {
|
|
258
|
+
for (const c of cs.changes) {
|
|
259
|
+
if (c.after === null)
|
|
260
|
+
continue;
|
|
261
|
+
const abs = safePath(root, c.path);
|
|
262
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
263
|
+
const tmp = `${abs}.codz-tmp`;
|
|
264
|
+
writeFileSync(tmp, c.after);
|
|
265
|
+
temps.push({ tmp, abs });
|
|
266
|
+
}
|
|
267
|
+
for (const t of temps)
|
|
268
|
+
renameSync(t.tmp, t.abs);
|
|
269
|
+
for (const c of cs.changes)
|
|
270
|
+
if (c.after === null)
|
|
271
|
+
unlinkSync(safePath(root, c.path));
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
for (const t of temps)
|
|
275
|
+
if (exists(t.tmp))
|
|
276
|
+
rmSync(t.tmp, { force: true });
|
|
277
|
+
restore(root, cs.id, cs.changes.map((c) => ({ path: c.path, created: c.before === null, deleted: c.after === null })));
|
|
278
|
+
throw new ToolError('write_failed', `Writing files failed, and everything was restored: ${e.message}`);
|
|
279
|
+
}
|
|
280
|
+
const result = { files, formatted: false, verified: false };
|
|
281
|
+
if (opts.format !== false) {
|
|
282
|
+
const fmt = opts.adapter?.formatCommand?.(root, files);
|
|
283
|
+
if (fmt) {
|
|
284
|
+
const r = run(fmt.cmd, fmt.args, root);
|
|
285
|
+
if (r.missing)
|
|
286
|
+
result.formatNote = `Formatter "${fmt.cmd}" was not found, so files were left as generated.`;
|
|
287
|
+
else if (!r.ok)
|
|
288
|
+
result.formatNote = `Formatter "${fmt.cmd}" reported a problem; files were left as generated. ${r.output.trim().split('\n')[0] ?? ''}`;
|
|
289
|
+
else
|
|
290
|
+
result.formatted = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (verifyCmd) {
|
|
294
|
+
const after = run(verifyCmd.cmd, verifyCmd.args, root);
|
|
295
|
+
if (after.missing) {
|
|
296
|
+
result.verifyNote = `Analyzer "${verifyCmd.cmd}" was not found, so the result was not checked.`;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const counts = errorCounts(after.output, files);
|
|
300
|
+
const worse = files.filter((f) => (counts.get(f) ?? 0) > (baseline.get(f) ?? 0));
|
|
301
|
+
if (worse.length) {
|
|
302
|
+
restore(root, cs.id, cs.changes.map((c) => ({ path: c.path, created: c.before === null, deleted: c.after === null })));
|
|
303
|
+
const lines = after.output.split('\n').filter((l) => worse.some((f) => l.includes(f)) && /error/i.test(l)).slice(0, 8);
|
|
304
|
+
throw new ToolError('verify_failed', `The analyzer found new errors in ${worse.join(', ')}, so the change was reverted.\n${lines.join('\n')}`, 'Run with --no-verify to apply anyway and fix the errors by hand.');
|
|
305
|
+
}
|
|
306
|
+
result.verified = true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const history = readHistory(root);
|
|
310
|
+
history.push({
|
|
311
|
+
id: cs.id,
|
|
312
|
+
skill: cs.skill,
|
|
313
|
+
summary: cs.summary,
|
|
314
|
+
files: cs.changes.map((c) => (c.after === null ? { path: c.path, created: false, deleted: true, afterHash: '' } : { path: c.path, created: c.before === null, afterHash: hash(readText(safePath(root, c.path)) ?? '') })),
|
|
315
|
+
generated: cs.generated,
|
|
316
|
+
undone: false,
|
|
317
|
+
});
|
|
318
|
+
writeHistory(root, history);
|
|
319
|
+
invalidateMap(root);
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
function restore(root, id, files) {
|
|
323
|
+
const backupDir = join(root, TOOL_DIR, 'backups', id);
|
|
324
|
+
for (const f of files) {
|
|
325
|
+
const abs = safePath(root, f.path);
|
|
326
|
+
if (f.created) {
|
|
327
|
+
if (exists(abs))
|
|
328
|
+
unlinkSync(abs);
|
|
329
|
+
// Folders the change created (lib/features/orders/) go too, once they are empty; never the project root.
|
|
330
|
+
for (let dir = dirname(abs); dir.startsWith(resolve(root) + sep); dir = dirname(dir)) {
|
|
331
|
+
try {
|
|
332
|
+
if (readdirSync(dir).length)
|
|
333
|
+
break;
|
|
334
|
+
rmdirSync(dir);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const original = readText(join(backupDir, f.path));
|
|
343
|
+
if (original !== null) {
|
|
344
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
345
|
+
writeFileSync(abs, original);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
export function undoLast(root, opts = {}) {
|
|
351
|
+
return withProjectLock(root, () => undoLastLocked(root, opts));
|
|
352
|
+
}
|
|
353
|
+
function undoLastLocked(root, opts) {
|
|
354
|
+
const history = readHistory(root);
|
|
355
|
+
const entry = [...history].reverse().find((e) => !e.undone);
|
|
356
|
+
if (!entry)
|
|
357
|
+
throw new ToolError('nothing_to_undo', 'There is nothing to undo.');
|
|
358
|
+
const changed = entry.files.filter((f) => {
|
|
359
|
+
const current = readText(safePath(root, f.path));
|
|
360
|
+
if (f.deleted)
|
|
361
|
+
return current !== null; // something new was written where codz deleted a file
|
|
362
|
+
return current === null || hash(current) !== f.afterHash;
|
|
363
|
+
});
|
|
364
|
+
if (changed.length && !opts.force) {
|
|
365
|
+
throw new ToolError('changed_since_apply', `These files changed after codz wrote them: ${changed.map((f) => f.path).join(', ')}. Undo would lose those edits.`, 'Pass --force to undo anyway.');
|
|
366
|
+
}
|
|
367
|
+
restore(root, entry.id, entry.files);
|
|
368
|
+
entry.undone = true;
|
|
369
|
+
writeHistory(root, history);
|
|
370
|
+
invalidateMap(root);
|
|
371
|
+
return { id: entry.id, skill: entry.skill, files: entry.files.map((f) => f.path) };
|
|
372
|
+
}
|
|
373
|
+
/** Undoes changes from the newest back to (and including) `id`. Stops at the first conflict. */
|
|
374
|
+
export function undoTo(root, id, opts = {}) {
|
|
375
|
+
const history = readHistory(root);
|
|
376
|
+
const target = history.find((e) => e.id === id || e.id.startsWith(id));
|
|
377
|
+
if (!target)
|
|
378
|
+
throw new ToolError('no_change', `No applied change with id "${id}".`, 'List them with: codz history');
|
|
379
|
+
if (target.undone)
|
|
380
|
+
throw new ToolError('already_undone', `${target.id} is already undone.`);
|
|
381
|
+
const done = [];
|
|
382
|
+
for (;;) {
|
|
383
|
+
const r = undoLast(root, opts);
|
|
384
|
+
done.push(r);
|
|
385
|
+
if (r.id === target.id)
|
|
386
|
+
return done;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** One applied change: its files, whether each still matches what codz wrote, and the diff it made. */
|
|
390
|
+
export function historyShow(root, id) {
|
|
391
|
+
const entry = readHistory(root).find((e) => e.id === id || e.id.startsWith(id));
|
|
392
|
+
if (!entry)
|
|
393
|
+
throw new ToolError('no_change', `No applied change with id "${id}".`, 'List them with: codz history');
|
|
394
|
+
const backupDir = join(root, TOOL_DIR, 'backups', entry.id);
|
|
395
|
+
const files = entry.files.map((f) => {
|
|
396
|
+
const current = readText(safePath(root, f.path));
|
|
397
|
+
const before = f.created ? null : readText(join(backupDir, f.path));
|
|
398
|
+
const action = f.created ? 'created' : f.deleted ? 'deleted' : 'changed';
|
|
399
|
+
const state = f.deleted ? (current === null ? 'as applied' : 'edited since') : current === null ? 'missing' : hash(current) === f.afterHash ? 'as applied' : 'edited since';
|
|
400
|
+
const after = f.deleted ? null : state === 'as applied' ? current : null;
|
|
401
|
+
return { path: f.path, action, state, diff: state === 'as applied' || f.deleted ? unifiedDiff(f.path, before, after) : undefined };
|
|
402
|
+
});
|
|
403
|
+
return { entry, files };
|
|
404
|
+
}
|