codz-cli 0.0.0-stage → 0.1.0
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 +523 -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 +358 -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,172 @@
|
|
|
1
|
+
// Checks codz.config.yaml: it parses, uses spaces, names a known framework,
|
|
2
|
+
// has known keys with allowed values, and its paths exist inside the project.
|
|
3
|
+
// Used by `codz config check`, `codz config set` and `codz doctor --setup`.
|
|
4
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { FLUTTER_CONVENTION_DOCS } from "../adapters/flutter.js";
|
|
6
|
+
import { NEXT_CONVENTION_DOCS } from "../adapters/nextjs.js";
|
|
7
|
+
import { RN_CONVENTION_DOCS } from "../adapters/reactNative.js";
|
|
8
|
+
import { REACT_CONVENTION_DOCS } from "../adapters/react.js";
|
|
9
|
+
import { IOS_CONVENTION_DOCS } from "../adapters/ios.js";
|
|
10
|
+
import { ANDROID_CONVENTION_DOCS } from "../adapters/android.js";
|
|
11
|
+
import { exists } from "./fsutil.js";
|
|
12
|
+
import { parse } from "./yaml.js";
|
|
13
|
+
const FRAMEWORKS = ['nextjs', 'flutter', 'react-native', 'react', 'android', 'ios', 'generic'];
|
|
14
|
+
/** Allowed values for keys that take one of a few words. Empty is always allowed (= detect). */
|
|
15
|
+
export const ALLOWED = {
|
|
16
|
+
nextjs: {
|
|
17
|
+
apiFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
|
|
18
|
+
modelFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
|
|
19
|
+
apiFileResource: ['plural', 'singular'],
|
|
20
|
+
modelsLayout: ['per-file', 'single'],
|
|
21
|
+
importStyle: ['alias', 'relative'],
|
|
22
|
+
quote: ['single', 'double'],
|
|
23
|
+
semicolons: ['true', 'false'],
|
|
24
|
+
indent: ['2', '4', 'tab'],
|
|
25
|
+
http: ['fetch', 'axios'],
|
|
26
|
+
dataFetching: ['react-query', 'none'],
|
|
27
|
+
},
|
|
28
|
+
'react-native': {
|
|
29
|
+
apiFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
|
|
30
|
+
modelFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
|
|
31
|
+
apiFileResource: ['plural', 'singular'],
|
|
32
|
+
modelsLayout: ['per-file', 'single'],
|
|
33
|
+
importStyle: ['alias', 'relative'],
|
|
34
|
+
quote: ['single', 'double'],
|
|
35
|
+
semicolons: ['true', 'false'],
|
|
36
|
+
indent: ['2', '4', 'tab'],
|
|
37
|
+
http: ['fetch', 'axios'],
|
|
38
|
+
dataFetching: ['react-query', 'none'],
|
|
39
|
+
flavor: ['expo', 'bare'],
|
|
40
|
+
router: ['react-navigation', 'expo-router', 'none'],
|
|
41
|
+
},
|
|
42
|
+
react: {
|
|
43
|
+
apiFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
|
|
44
|
+
modelFileNaming: ['kebab', 'camel', 'pascal', 'snake'],
|
|
45
|
+
apiFileResource: ['plural', 'singular'],
|
|
46
|
+
modelsLayout: ['per-file', 'single'],
|
|
47
|
+
importStyle: ['alias', 'relative'],
|
|
48
|
+
quote: ['single', 'double'],
|
|
49
|
+
semicolons: ['true', 'false'],
|
|
50
|
+
indent: ['2', '4', 'tab'],
|
|
51
|
+
http: ['fetch', 'axios'],
|
|
52
|
+
dataFetching: ['react-query', 'none'],
|
|
53
|
+
bundler: ['vite', 'cra'],
|
|
54
|
+
router: ['react-router', 'none'],
|
|
55
|
+
routerStyle: ['jsx', 'data'],
|
|
56
|
+
},
|
|
57
|
+
ios: {
|
|
58
|
+
ui: ['swiftui', 'uikit'],
|
|
59
|
+
fileSync: ['synchronized', 'groups'],
|
|
60
|
+
indent: ['2', '4'],
|
|
61
|
+
},
|
|
62
|
+
android: {
|
|
63
|
+
http: ['retrofit', 'ktor', 'none'],
|
|
64
|
+
serialization: ['kotlinx', 'moshi', 'gson'],
|
|
65
|
+
di: ['hilt', 'koin', 'manual', 'none'],
|
|
66
|
+
ui: ['compose', 'views'],
|
|
67
|
+
indent: ['2', '4'],
|
|
68
|
+
},
|
|
69
|
+
flutter: {
|
|
70
|
+
http: ['dio', 'http'],
|
|
71
|
+
serialization: ['manual', 'json_serializable', 'freezed'],
|
|
72
|
+
freezedClass: ['abstract', 'plain'],
|
|
73
|
+
apiStyle: ['repository', 'retrofit'],
|
|
74
|
+
repoKind: ['repository', 'service', 'api'],
|
|
75
|
+
di: ['getx', 'riverpod', 'get_it', 'none'],
|
|
76
|
+
importStyle: ['package', 'relative'],
|
|
77
|
+
indent: ['2', '4'],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
/** Keys whose values are paths inside the project. */
|
|
81
|
+
export const PATH_KEYS = new Set(['appDir', 'apiFolder', 'httpHelperFile', 'modelsFolder', 'modelsFile', 'repoFolder', 'dioProviderFile', 'diFile', 'networkFile', 'modulesFolder', 'routesFile', 'axiosInstanceFile', 'hooksFolder', 'templatesFolder', 'screensFolder', 'navigatorFile', 'routerDir', 'configFile', 'srcDir', 'navFile', 'module']);
|
|
82
|
+
/** Path keys that codz creates when missing (a fresh project), so a missing one is fine. */
|
|
83
|
+
const CREATED_ON_DEMAND = new Set(['screensFolder', 'navigatorFile', 'configFile', 'templatesFolder', 'hooksFolder', 'apiFolder', 'modelsFolder', 'repoFolder', 'modulesFolder', 'routesFile', 'modelsFile']);
|
|
84
|
+
/** The candidate closest to a misspelled word (at most two edits away), if any. */
|
|
85
|
+
export function closest(word, candidates) {
|
|
86
|
+
const dist = (a, b) => {
|
|
87
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
88
|
+
for (let j = 1; j <= b.length; j++)
|
|
89
|
+
d[0][j] = j;
|
|
90
|
+
for (let i = 1; i <= a.length; i++)
|
|
91
|
+
for (let j = 1; j <= b.length; j++)
|
|
92
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1] ? d[i - 2][j - 2] + 1 : Infinity);
|
|
93
|
+
return d[a.length][b.length];
|
|
94
|
+
};
|
|
95
|
+
let best;
|
|
96
|
+
let bestD = 3;
|
|
97
|
+
for (const c of candidates) {
|
|
98
|
+
const n = dist(word.toLowerCase(), c.toLowerCase());
|
|
99
|
+
if (n < bestD)
|
|
100
|
+
[best, bestD] = [c, n];
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
|
104
|
+
export function knownKeys(framework) {
|
|
105
|
+
return framework === 'nextjs' ? NEXT_CONVENTION_DOCS : framework === 'flutter' ? FLUTTER_CONVENTION_DOCS : framework === 'react-native' ? RN_CONVENTION_DOCS : framework === 'react' ? REACT_CONVENTION_DOCS : framework === 'ios' ? IOS_CONVENTION_DOCS : framework === 'android' ? ANDROID_CONVENTION_DOCS : {};
|
|
106
|
+
}
|
|
107
|
+
/** Checks one value; returns an issue or null. */
|
|
108
|
+
export function checkValue(framework, key, value, root) {
|
|
109
|
+
if (value === '')
|
|
110
|
+
return null;
|
|
111
|
+
const allowed = ALLOWED[framework]?.[key];
|
|
112
|
+
if (allowed && !allowed.includes(value)) {
|
|
113
|
+
return { level: 'error', key, message: `${key} is "${value}", which is not one of: ${allowed.join(', ')}.`, hint: `codz config set ${key} ${allowed[0]}` };
|
|
114
|
+
}
|
|
115
|
+
if (PATH_KEYS.has(key) && root) {
|
|
116
|
+
if (isAbsolute(value) || value.split(/[\\/]/).includes('..')) {
|
|
117
|
+
const outside = relative(resolve(root), resolve(root, value)).startsWith('..') || isAbsolute(value);
|
|
118
|
+
if (outside)
|
|
119
|
+
return { level: 'error', key, message: `${key} (${value}) points outside the project.`, hint: 'Use a path relative to the folder that holds codz.config.yaml.' };
|
|
120
|
+
}
|
|
121
|
+
if (!exists(join(root, value)) && !CREATED_ON_DEMAND.has(key)) {
|
|
122
|
+
return { level: 'warning', key, message: `${key} (${value}) does not exist.`, hint: `Fix it with codz config set ${key} <path>, or rescan with codz init --force.` };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
export function validateConfig(text, root) {
|
|
128
|
+
const issues = [];
|
|
129
|
+
const tabLine = text.split(/\r?\n/).findIndex((l) => /^\s*\t/.test(l));
|
|
130
|
+
if (tabLine !== -1) {
|
|
131
|
+
return [{ level: 'error', message: `Line ${tabLine + 1} is indented with a tab.`, hint: 'YAML needs spaces; replace the tab with two spaces.' }];
|
|
132
|
+
}
|
|
133
|
+
let data;
|
|
134
|
+
try {
|
|
135
|
+
data = parse(text);
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
return [{ level: 'error', message: e.message.replace(/^codz\.config\.yaml /, ''), hint: 'Fix the line, or recreate the file with codz init --force.' }];
|
|
139
|
+
}
|
|
140
|
+
for (const key of Object.keys(data)) {
|
|
141
|
+
if (key !== 'framework' && key !== 'conventions')
|
|
142
|
+
issues.push({ level: 'warning', key, message: `Unknown top-level key "${key}"; codz ignores it.` });
|
|
143
|
+
}
|
|
144
|
+
const framework = typeof data.framework === 'string' ? data.framework : '';
|
|
145
|
+
if (!framework)
|
|
146
|
+
issues.push({ level: 'error', key: 'framework', message: 'framework is missing.', hint: 'Add framework: nextjs or framework: flutter.' });
|
|
147
|
+
else if (!FRAMEWORKS.includes(framework))
|
|
148
|
+
issues.push({ level: 'error', key: 'framework', message: `framework "${framework}" is not one of: ${FRAMEWORKS.join(', ')}.` });
|
|
149
|
+
const conv = data.conventions;
|
|
150
|
+
if (conv === undefined)
|
|
151
|
+
return issues;
|
|
152
|
+
if (typeof conv !== 'object' || Array.isArray(conv)) {
|
|
153
|
+
issues.push({ level: 'error', key: 'conventions', message: 'conventions must be a list of key: value lines.' });
|
|
154
|
+
return issues;
|
|
155
|
+
}
|
|
156
|
+
const known = knownKeys(framework);
|
|
157
|
+
for (const [key, value] of Object.entries(conv)) {
|
|
158
|
+
if (typeof value !== 'string') {
|
|
159
|
+
issues.push({ level: 'error', key, message: `${key} must be a single value.` });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (Object.keys(known).length && !(key in known)) {
|
|
163
|
+
const close = closest(key, Object.keys(known));
|
|
164
|
+
issues.push({ level: 'warning', key, message: `Unknown convention "${key}"; codz ignores it.`, hint: close ? `Did you mean ${close}?` : undefined });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const issue = checkValue(framework, key, value, root);
|
|
168
|
+
if (issue)
|
|
169
|
+
issues.push(issue);
|
|
170
|
+
}
|
|
171
|
+
return issues;
|
|
172
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Workflow helpers around a change: commit it, open its diff in an editor, read
|
|
2
|
+
// input from the clipboard, and summarize a change set in one line.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { TOOL_DIR } from "./cache.js";
|
|
7
|
+
import { diffStats, unifiedDiff } from "./diff.js";
|
|
8
|
+
import { isGitRepo } from "./git.js";
|
|
9
|
+
import { ToolError } from "./types.js";
|
|
10
|
+
/** "3 files: 1 created, 2 changed, 0 deleted (+40 −3 lines)". */
|
|
11
|
+
export function changeSummaryLine(cs) {
|
|
12
|
+
let created = 0;
|
|
13
|
+
let changed = 0;
|
|
14
|
+
let deleted = 0;
|
|
15
|
+
let added = 0;
|
|
16
|
+
let removed = 0;
|
|
17
|
+
for (const c of cs.changes) {
|
|
18
|
+
if (c.before === null)
|
|
19
|
+
created++;
|
|
20
|
+
else if (c.after === null)
|
|
21
|
+
deleted++;
|
|
22
|
+
else
|
|
23
|
+
changed++;
|
|
24
|
+
const s = diffStats(c.before, c.after);
|
|
25
|
+
added += s.added;
|
|
26
|
+
removed += s.removed;
|
|
27
|
+
}
|
|
28
|
+
const n = cs.changes.length;
|
|
29
|
+
const parts = [created && `${created} created`, changed && `${changed} changed`, deleted && `${deleted} deleted`].filter(Boolean).join(', ');
|
|
30
|
+
return `${n} file${n === 1 ? '' : 's'}: ${parts} (+${added} −${removed} lines)`;
|
|
31
|
+
}
|
|
32
|
+
/** Commits exactly the files of a change. Returns the commit hash. */
|
|
33
|
+
export function commitChange(root, cs, message) {
|
|
34
|
+
if (!isGitRepo(root))
|
|
35
|
+
throw new ToolError('not_a_repo', '--commit needs a git repository.', 'Run git init first, or leave out --commit.');
|
|
36
|
+
const paths = cs.changes.map((c) => c.path);
|
|
37
|
+
const add = spawnSync('git', ['add', '-A', '--', ...paths], { cwd: root, encoding: 'utf8' });
|
|
38
|
+
if (add.status !== 0)
|
|
39
|
+
throw new ToolError('commit_failed', `git add failed: ${add.stderr.trim()}`);
|
|
40
|
+
const msg = message?.trim() || `codz ${cs.skill}: ${cs.summary[0] ?? cs.id}`.slice(0, 200);
|
|
41
|
+
const body = message?.trim() ? '' : `\n\n${cs.summary.slice(1, 15).map((s) => `- ${s.trim()}`).join('\n')}\n\ncodz change ${cs.id}`;
|
|
42
|
+
const commit = spawnSync('git', ['commit', '-m', msg + body, '--', ...paths], { cwd: root, encoding: 'utf8' });
|
|
43
|
+
if (commit.status !== 0)
|
|
44
|
+
throw new ToolError('commit_failed', `The change was applied, but git commit failed: ${(commit.stderr || commit.stdout).trim().split('\n')[0]}`, 'Commit it yourself, or undo it with codz undo.');
|
|
45
|
+
return spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim();
|
|
46
|
+
}
|
|
47
|
+
/** Writes the diff to a file and opens it in $VISUAL / $EDITOR; returns false when no editor is set. */
|
|
48
|
+
export function openDiffInEditor(root, cs) {
|
|
49
|
+
const editor = process.env.VISUAL || process.env.EDITOR;
|
|
50
|
+
if (!editor)
|
|
51
|
+
return false;
|
|
52
|
+
const dir = join(root, TOOL_DIR, 'tmp');
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
const file = join(dir, `${cs.id}.diff`);
|
|
55
|
+
writeFileSync(file, cs.changes.map((c) => unifiedDiff(c.path, c.before, c.after)).join('\n'));
|
|
56
|
+
const r = spawnSync(editor, [file], { stdio: 'inherit', shell: true });
|
|
57
|
+
return r.status === 0 || r.status === null;
|
|
58
|
+
}
|
|
59
|
+
/** Reads text from the system clipboard. */
|
|
60
|
+
export function readClipboard() {
|
|
61
|
+
const tries = process.platform === 'darwin'
|
|
62
|
+
? [['pbpaste', []]]
|
|
63
|
+
: process.platform === 'win32'
|
|
64
|
+
? [['powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw']]]
|
|
65
|
+
: [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]];
|
|
66
|
+
for (const [cmd, args] of tries) {
|
|
67
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 5000 });
|
|
68
|
+
if (r.status === 0 && r.stdout.trim())
|
|
69
|
+
return r.stdout;
|
|
70
|
+
}
|
|
71
|
+
throw new ToolError('no_clipboard', 'Could not read the clipboard, or it is empty.', process.platform === 'linux' ? 'Install wl-clipboard, xclip or xsel, or paste on standard input instead.' : 'Copy the cURL command first, or paste it on standard input instead.');
|
|
72
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// A private copy of the project for commands that chain several generators
|
|
2
|
+
// (feature add, auth add). Each step plans and applies against the copy, so a
|
|
3
|
+
// later step sees what earlier steps wrote; at the end the copy is compared
|
|
4
|
+
// with the real project and the difference becomes ONE change set, previewed
|
|
5
|
+
// and undone like any other. The real project is never touched while planning.
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
9
|
+
import { applyChangeSet, buildChangeSet } from "./changeset.js";
|
|
10
|
+
import { TOOL_DIR } from "./cache.js";
|
|
11
|
+
/** Folders that are generated, installed or huge; never copied and never compared. */
|
|
12
|
+
const SKIP = new Set(['node_modules', '.git', '.dart_tool', 'build', '.gradle', '.next', 'Pods', '.expo', 'dist', 'out', '.idea', 'coverage', '.turbo', '.cache', TOOL_DIR, '.kotlin', '.cxx', '.externalNativeBuild']);
|
|
13
|
+
/** Files larger than this are copied but never read as text for the comparison. */
|
|
14
|
+
const MAX_TEXT = 2 * 1024 * 1024;
|
|
15
|
+
const keep = (root) => (src) => {
|
|
16
|
+
const rel = relative(root, src);
|
|
17
|
+
if (!rel)
|
|
18
|
+
return true;
|
|
19
|
+
return !rel.split(sep).some((part) => SKIP.has(part));
|
|
20
|
+
};
|
|
21
|
+
function walk(root, dir = root, out = []) {
|
|
22
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
if (SKIP.has(e.name))
|
|
24
|
+
continue;
|
|
25
|
+
const abs = join(dir, e.name);
|
|
26
|
+
if (e.isDirectory())
|
|
27
|
+
walk(root, abs, out);
|
|
28
|
+
else if (e.isFile())
|
|
29
|
+
out.push(relative(root, abs).split(sep).join('/'));
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
export function openWorkspace(realRoot) {
|
|
34
|
+
const root = mkdtempSync(join(tmpdir(), 'codz-ws-'));
|
|
35
|
+
cpSync(realRoot, root, { recursive: true, filter: keep(realRoot), verbatimSymlinks: true });
|
|
36
|
+
const generated = [];
|
|
37
|
+
const rewrites = new Set();
|
|
38
|
+
return {
|
|
39
|
+
root,
|
|
40
|
+
apply(cs) {
|
|
41
|
+
if (!cs.changes.length)
|
|
42
|
+
return false;
|
|
43
|
+
applyChangeSet(root, cs, { allowDirty: true, format: false, verify: false });
|
|
44
|
+
generated.push(...cs.generated);
|
|
45
|
+
for (const r of cs.rewrites ?? [])
|
|
46
|
+
rewrites.add(r);
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
read(rel) {
|
|
50
|
+
const abs = join(root, rel);
|
|
51
|
+
return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
|
|
52
|
+
},
|
|
53
|
+
write(rel, text) {
|
|
54
|
+
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
55
|
+
writeFileSync(join(root, rel), text);
|
|
56
|
+
},
|
|
57
|
+
finish(skill, summary, warnings) {
|
|
58
|
+
const files = new Map();
|
|
59
|
+
const deletes = new Set();
|
|
60
|
+
for (const rel of walk(root)) {
|
|
61
|
+
const copy = join(root, rel);
|
|
62
|
+
if (statSync(copy).size > MAX_TEXT)
|
|
63
|
+
continue;
|
|
64
|
+
const after = readFileSync(copy);
|
|
65
|
+
const real = join(realRoot, rel);
|
|
66
|
+
const before = existsSync(real) ? readFileSync(real) : null;
|
|
67
|
+
if (before && before.equals(after))
|
|
68
|
+
continue;
|
|
69
|
+
files.set(rel, after.toString('utf8'));
|
|
70
|
+
}
|
|
71
|
+
for (const rel of walk(realRoot))
|
|
72
|
+
if (!existsSync(join(root, rel)))
|
|
73
|
+
deletes.add(rel);
|
|
74
|
+
return buildChangeSet(realRoot, skill, { files, deletes, summary, warnings, generated, rewrites: [...rewrites] });
|
|
75
|
+
},
|
|
76
|
+
dispose() {
|
|
77
|
+
rmSync(root, { recursive: true, force: true });
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ToolError } from "./types.js";
|
|
2
|
+
const PLAIN = /^[A-Za-z0-9_./{}+-][A-Za-z0-9_ ./@{}:+-]*$/;
|
|
3
|
+
export function scalar(value) {
|
|
4
|
+
if (value === '')
|
|
5
|
+
return "''";
|
|
6
|
+
if (PLAIN.test(value) && !value.includes(': ') && !value.endsWith(':') && !/^(true|false|null|~|yes|no)$/i.test(value)) {
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
10
|
+
}
|
|
11
|
+
export function stringify(value, comments = {}, indent = 0, prefix = '') {
|
|
12
|
+
const pad = ' '.repeat(indent);
|
|
13
|
+
let out = '';
|
|
14
|
+
for (const [key, v] of Object.entries(value)) {
|
|
15
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
16
|
+
if (comments[path])
|
|
17
|
+
out += `${pad}# ${comments[path]}\n`;
|
|
18
|
+
if (typeof v === 'string') {
|
|
19
|
+
out += `${pad}${key}: ${scalar(v)}\n`;
|
|
20
|
+
}
|
|
21
|
+
else if (Array.isArray(v)) {
|
|
22
|
+
if (v.length === 0) {
|
|
23
|
+
out += `${pad}${key}: []\n`;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
out += `${pad}${key}:\n`;
|
|
27
|
+
for (const item of v)
|
|
28
|
+
out += `${pad} - ${scalar(String(item))}\n`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const entries = Object.keys(v);
|
|
33
|
+
out += entries.length === 0 ? `${pad}${key}: {}\n` : `${pad}${key}:\n${stringify(v, comments, indent + 2, path)}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function unquote(raw) {
|
|
39
|
+
const s = raw.trim();
|
|
40
|
+
if (s.startsWith("'") && s.endsWith("'") && s.length >= 2)
|
|
41
|
+
return s.slice(1, -1).replace(/''/g, "'");
|
|
42
|
+
if (s.startsWith('"') && s.endsWith('"') && s.length >= 2) {
|
|
43
|
+
return s.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
44
|
+
}
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
function stripComment(line) {
|
|
48
|
+
let inSingle = false;
|
|
49
|
+
let inDouble = false;
|
|
50
|
+
for (let i = 0; i < line.length; i++) {
|
|
51
|
+
const c = line[i];
|
|
52
|
+
if (c === "'" && !inDouble)
|
|
53
|
+
inSingle = !inSingle;
|
|
54
|
+
else if (c === '"' && !inSingle)
|
|
55
|
+
inDouble = !inDouble;
|
|
56
|
+
else if (c === '#' && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1])))
|
|
57
|
+
return line.slice(0, i);
|
|
58
|
+
}
|
|
59
|
+
return line;
|
|
60
|
+
}
|
|
61
|
+
export function parse(source) {
|
|
62
|
+
const lines = [];
|
|
63
|
+
source.split(/\r?\n/).forEach((raw, i) => {
|
|
64
|
+
const noComment = stripComment(raw).replace(/\s+$/, '');
|
|
65
|
+
if (noComment.trim() === '')
|
|
66
|
+
return;
|
|
67
|
+
if (/\t/.test(noComment.match(/^\s*/)[0]))
|
|
68
|
+
throw new ToolError('bad_config', `codz.config.yaml line ${i + 1}: use spaces, not tabs`);
|
|
69
|
+
lines.push({ indent: noComment.length - noComment.trimStart().length, text: noComment.trim(), no: i + 1 });
|
|
70
|
+
});
|
|
71
|
+
let pos = 0;
|
|
72
|
+
function parseMap(indent) {
|
|
73
|
+
const out = {};
|
|
74
|
+
while (pos < lines.length && lines[pos].indent === indent && !lines[pos].text.startsWith('- ')) {
|
|
75
|
+
const line = lines[pos];
|
|
76
|
+
const m = line.text.match(/^([^:]+?):(?:\s+(.*))?$/);
|
|
77
|
+
if (!m)
|
|
78
|
+
throw new ToolError('bad_config', `codz.config.yaml line ${line.no}: expected "key: value"`);
|
|
79
|
+
const key = unquote(m[1]);
|
|
80
|
+
const rest = m[2];
|
|
81
|
+
pos++;
|
|
82
|
+
if (rest === undefined || rest === '') {
|
|
83
|
+
if (pos < lines.length && lines[pos].indent > indent) {
|
|
84
|
+
out[key] = lines[pos].text.startsWith('- ') ? parseList(lines[pos].indent) : parseMap(lines[pos].indent);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
out[key] = '';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (rest === '[]') {
|
|
91
|
+
out[key] = [];
|
|
92
|
+
}
|
|
93
|
+
else if (rest === '{}') {
|
|
94
|
+
out[key] = {};
|
|
95
|
+
}
|
|
96
|
+
else if (rest.startsWith('[') && rest.endsWith(']')) {
|
|
97
|
+
out[key] = rest
|
|
98
|
+
.slice(1, -1)
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((s) => unquote(s))
|
|
101
|
+
.filter((s) => s !== '');
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
out[key] = unquote(rest);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (pos < lines.length && lines[pos].indent > indent) {
|
|
108
|
+
throw new ToolError('bad_config', `codz.config.yaml line ${lines[pos].no}: unexpected indentation`);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function parseList(indent) {
|
|
113
|
+
const out = [];
|
|
114
|
+
while (pos < lines.length && lines[pos].indent === indent && lines[pos].text.startsWith('- ')) {
|
|
115
|
+
out.push(unquote(lines[pos].text.slice(2)));
|
|
116
|
+
pos++;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
if (lines.length === 0)
|
|
121
|
+
return {};
|
|
122
|
+
const result = parseMap(lines[0].indent);
|
|
123
|
+
if (pos < lines.length)
|
|
124
|
+
throw new ToolError('bad_config', `codz.config.yaml line ${lines[pos].no}: could not parse`);
|
|
125
|
+
return result;
|
|
126
|
+
}
|