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,283 @@
|
|
|
1
|
+
// codz new, starters list|show|cache, login, logout, whoami, and applying
|
|
2
|
+
// a saved `new` plan. The main CLI loads this file only when one of these
|
|
3
|
+
// commands runs, so the rest of codz never loads network code.
|
|
4
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { newId } from "../core/changeset.js";
|
|
7
|
+
import { hash } from "../core/fsutil.js";
|
|
8
|
+
import { ToolError } from "../core/types.js";
|
|
9
|
+
import { auth } from "./auth.js";
|
|
10
|
+
import { applyNew, detectSdks, planNew, renderPlan, undoNew } from "./engine.js";
|
|
11
|
+
import { homePath, readHomeFile, removeHome, writeHomeFile } from "./home.js";
|
|
12
|
+
import { setAnnouncer } from "./http.js";
|
|
13
|
+
import { checkAnswer, resolveAnswers } from "./manifest.js";
|
|
14
|
+
import { fetchManifest, fetchTemplate, latestUsable, loadIndex, resolveRef } from "./registry.js";
|
|
15
|
+
import { fromFolder } from "./source.js";
|
|
16
|
+
import { printable } from "./strictJson.js";
|
|
17
|
+
const str = (flags, k) => (typeof flags[k] === 'string' ? flags[k] : '');
|
|
18
|
+
function isFolderRef(ref, cwd) {
|
|
19
|
+
return /^(\.{1,2}[\\/]|[\\/]|~[\\/]|[a-zA-Z]:[\\/])/.test(ref) || /[\\/]/.test(ref) || (existsSync(resolve(cwd, ref)) && statSync(resolve(cwd, ref)).isDirectory());
|
|
20
|
+
}
|
|
21
|
+
async function loadTemplate(ref, ctx, cached) {
|
|
22
|
+
if (isFolderRef(ref, ctx.cwd)) {
|
|
23
|
+
const dir = resolve(ctx.cwd, ref.replace(/^~(?=[\\/])/, process.env.HOME ?? '~'));
|
|
24
|
+
return { template: fromFolder(dir), ref: dir, kind: 'folder', warnings: [] };
|
|
25
|
+
}
|
|
26
|
+
const { index, warnings } = await loadIndex({ cached });
|
|
27
|
+
const r = resolveRef(index, ref);
|
|
28
|
+
const template = await fetchTemplate(index, r.template, r.version, { cached });
|
|
29
|
+
return { template, ref: `${r.template.id}@${r.version.version}`, kind: 'catalog', warnings: [...warnings, ...r.warnings] };
|
|
30
|
+
}
|
|
31
|
+
async function pickFromCatalog(ctx) {
|
|
32
|
+
const { index, warnings } = await loadIndex({ cached: ctx.flags.cached === true });
|
|
33
|
+
for (const w of warnings)
|
|
34
|
+
ctx.io.warn(w);
|
|
35
|
+
const f = (k) => str(ctx.flags, k);
|
|
36
|
+
const list = index.templates.filter((t) => (!f('framework') || t.framework === f('framework')) && ['state', 'router', 'http'].every((k) => !f(k) || t.provides?.[k] === f(k)));
|
|
37
|
+
if (!list.length)
|
|
38
|
+
throw new ToolError('template_not_found', 'No starter matches those choices.', 'See them all with: codz starters list');
|
|
39
|
+
if (!ctx.io.choose)
|
|
40
|
+
throw new ToolError('bad_args', 'Which starter? Pass --template <id> (or a folder).', `Starters: ${list.map((t) => t.id).join(', ')}`);
|
|
41
|
+
const i = await ctx.io.choose('Which starter?', list.map((t) => `${t.id} ${printable(t.title)}${t.tier === 'paid' ? ` (paid${t.price ? `, ${printable(t.price)}` : ''})` : ' (free)'}`), 0);
|
|
42
|
+
if (i === null)
|
|
43
|
+
throw new ToolError('cancelled', 'No starter chosen.');
|
|
44
|
+
const t = list[i];
|
|
45
|
+
ctx.io.print(`Same choice in a script: codz new <name> --template ${t.id}@${latestUsable(t, index)}`);
|
|
46
|
+
return `${t.id}@${latestUsable(t, index)}`;
|
|
47
|
+
}
|
|
48
|
+
async function collectAnswers(m, ctx) {
|
|
49
|
+
const given = {};
|
|
50
|
+
for (const s of ctx.multi.set ?? []) {
|
|
51
|
+
const eq = s.indexOf('=');
|
|
52
|
+
if (eq <= 0)
|
|
53
|
+
throw new ToolError('bad_args', `--set needs key=value, not "${s.slice(0, 60)}".`);
|
|
54
|
+
given[s.slice(0, eq)] = s.slice(eq + 1);
|
|
55
|
+
}
|
|
56
|
+
if (ctx.positional[1])
|
|
57
|
+
given.appName = ctx.positional[1];
|
|
58
|
+
if (str(ctx.flags, 'org') && m.answers.some((a) => a.key === 'org'))
|
|
59
|
+
given.org = str(ctx.flags, 'org');
|
|
60
|
+
if (ctx.io.ask) {
|
|
61
|
+
for (const a of m.answers) {
|
|
62
|
+
if (a.key in given)
|
|
63
|
+
continue;
|
|
64
|
+
if (a.type === 'boolean' && ctx.io.confirm) {
|
|
65
|
+
const v = await ctx.io.confirm(printable(a.prompt));
|
|
66
|
+
if (v !== null)
|
|
67
|
+
given[a.key] = String(v);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (a.type === 'choice' && ctx.io.choose) {
|
|
71
|
+
const def = Math.max(0, a.choices.indexOf(String(a.default ?? '')));
|
|
72
|
+
const i = await ctx.io.choose(printable(a.prompt), a.choices.map(printable), def);
|
|
73
|
+
if (i !== null)
|
|
74
|
+
given[a.key] = a.choices[i];
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const v = await ctx.io.ask(`${printable(a.prompt)}${a.default !== undefined ? ` [${printable(String(a.default))}]` : ''}: `, (x) => (!x && a.default !== undefined ? null : checkAnswer(a, x)));
|
|
78
|
+
if (v === null)
|
|
79
|
+
throw new ToolError('cancelled', 'Not created.');
|
|
80
|
+
if (v)
|
|
81
|
+
given[a.key] = v;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return given;
|
|
85
|
+
}
|
|
86
|
+
function planDigest(p) {
|
|
87
|
+
return hash(JSON.stringify([p.target, p.template.manifestSha256, p.template.archiveSha256 ?? '', p.files.map((f) => [f.path, f.mode, hash(f.data.toString('base64'))]), p.steps.map((s) => s.command), p.dependencies, p.devDependencies]));
|
|
88
|
+
}
|
|
89
|
+
function planJson(p) {
|
|
90
|
+
return {
|
|
91
|
+
template: { id: p.template.id, version: p.template.version, title: p.template.title, sha256: p.template.archiveSha256 ?? null, license: p.template.license },
|
|
92
|
+
target: p.target,
|
|
93
|
+
files: p.files.map((f) => ({ path: f.path, action: 'create', mode: f.mode })),
|
|
94
|
+
steps: p.steps.map((s) => s.id),
|
|
95
|
+
dependencies: p.dependencies,
|
|
96
|
+
apiAdd: p.apiAdd,
|
|
97
|
+
warnings: p.warnings,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async function runNew(ctx) {
|
|
101
|
+
const { flags, io } = ctx;
|
|
102
|
+
const cached = flags.cached === true;
|
|
103
|
+
const ref = str(flags, 'template') || (await pickFromCatalog(ctx));
|
|
104
|
+
const loaded = await loadTemplate(ref, ctx, cached);
|
|
105
|
+
for (const w of loaded.warnings)
|
|
106
|
+
io.warn(w);
|
|
107
|
+
const m = loaded.template.manifest;
|
|
108
|
+
const answers = resolveAnswers(m, await collectAnswers(m, ctx));
|
|
109
|
+
const parent = resolve(ctx.cwd, str(flags, 'dir') || '.');
|
|
110
|
+
const sdks = detectSdks(m.framework === 'flutter');
|
|
111
|
+
const plan = planNew(loaded.template, answers, parent, ctx.version, sdks, { steps: flags['no-steps'] !== true });
|
|
112
|
+
if (existsSync(plan.target) && readdirSync(plan.target).length)
|
|
113
|
+
throw new ToolError('dir_not_empty', `${plan.target} already exists and is not empty.`, 'Pick another name, or an empty folder.');
|
|
114
|
+
io.print(renderPlan(plan));
|
|
115
|
+
for (const w of plan.warnings)
|
|
116
|
+
io.warn(w);
|
|
117
|
+
if (flags['dry-run'] === true) {
|
|
118
|
+
io.result({ dryRun: true, ...planJson(plan) });
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
const yes = flags.yes === true || flags.y === true;
|
|
122
|
+
if (flags.plan === true || (io.json && !yes)) {
|
|
123
|
+
const id = newId();
|
|
124
|
+
const saved = { id, ref: loaded.ref, kind: loaded.kind, parent, answers: Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, String(v)])), steps: flags['no-steps'] !== true, digest: planDigest(plan), archiveSha256: plan.template.archiveSha256 };
|
|
125
|
+
writeHomeFile(JSON.stringify(saved, null, 2), {}, 'starters', 'plans', `${id}.json`);
|
|
126
|
+
io.print(`\nSaved plan ${id}. Create exactly this with: codz apply ${id}`);
|
|
127
|
+
io.result({ planId: id, ...planJson(plan) });
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
if (!yes) {
|
|
131
|
+
if (!io.confirm)
|
|
132
|
+
throw new ToolError('needs_confirmation', 'Not created: pass --yes to create it without a question, or --plan to save it.');
|
|
133
|
+
if (!(await io.confirm('\nCreate it?'))) {
|
|
134
|
+
io.print('Not created.');
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return finishNew(plan, loaded, io);
|
|
139
|
+
}
|
|
140
|
+
function finishNew(plan, loaded, io) {
|
|
141
|
+
const r = applyNew(plan, (l) => io.print(l));
|
|
142
|
+
for (const w of r.warnings)
|
|
143
|
+
io.warn(w);
|
|
144
|
+
const runs = readHomeFile('starters', 'runs.json');
|
|
145
|
+
let list = [];
|
|
146
|
+
try {
|
|
147
|
+
list = runs ? JSON.parse(runs.toString('utf8')) : [];
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
list = [];
|
|
151
|
+
}
|
|
152
|
+
list.push({ id: r.id, template: plan.template.id, version: plan.template.version, archiveSha256: plan.template.archiveSha256 ?? null, source: loaded.kind, target: r.target, at: new Date().toISOString() });
|
|
153
|
+
writeHomeFile(JSON.stringify(list.slice(-200), null, 2), {}, 'starters', 'runs.json');
|
|
154
|
+
const name = plan.target.split(/[\\/]/).pop();
|
|
155
|
+
io.print(`\nCreated ${r.target} (${r.files} files).`);
|
|
156
|
+
if (!plan.apiAdd.supported)
|
|
157
|
+
io.warn(`codz init and explain work on this project, but api add does not support it${plan.apiAdd.note ? ` (${plan.apiAdd.note})` : ''}.`);
|
|
158
|
+
io.print(`Next: cd ${name} && codz init`);
|
|
159
|
+
io.print('Undo (inside the project, before you change it): codz undo');
|
|
160
|
+
io.result({ id: r.id, target: r.target, files: r.files, steps: r.steps, warnings: r.warnings, template: { id: plan.template.id, version: plan.template.version, sha256: plan.template.archiveSha256 ?? null } });
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
/** codz apply <id> for a plan saved by codz new --plan. Returns null when the id is not a starter plan. */
|
|
164
|
+
export async function applyStarterPlan(id, ctx) {
|
|
165
|
+
const b = readHomeFile('starters', 'plans', `${id}.json`);
|
|
166
|
+
if (!b)
|
|
167
|
+
return null;
|
|
168
|
+
const saved = JSON.parse(b.toString('utf8'));
|
|
169
|
+
const loaded = saved.kind === 'folder' ? { template: fromFolder(saved.ref), ref: saved.ref, kind: 'folder', warnings: [] } : await loadTemplate(saved.ref, ctx, ctx.flags.cached === true);
|
|
170
|
+
const answers = resolveAnswers(loaded.template.manifest, saved.answers);
|
|
171
|
+
const plan = planNew(loaded.template, answers, saved.parent, ctx.version, detectSdks(loaded.template.manifest.framework === 'flutter'), { steps: saved.steps });
|
|
172
|
+
if (planDigest(plan) !== saved.digest || (existsSync(plan.target) && readdirSync(plan.target).length))
|
|
173
|
+
throw new ToolError('changed_since_plan', 'The template or the target folder changed after the preview, so nothing was created.', 'Run codz new again for a fresh preview.');
|
|
174
|
+
const code = finishNew(plan, loaded, ctx.io);
|
|
175
|
+
removeHome('starters', 'plans', `${id}.json`);
|
|
176
|
+
return code;
|
|
177
|
+
}
|
|
178
|
+
export function isStarterPlan(id) {
|
|
179
|
+
return readHomeFile('starters', 'plans', `${id}.json`) !== null;
|
|
180
|
+
}
|
|
181
|
+
// ---------- starters list | show | cache ----------
|
|
182
|
+
function line(t, latest, owned) {
|
|
183
|
+
const tier = t.tier === 'paid' ? (owned.has(t.id) ? 'owned' : `paid${t.price ? ` ${printable(t.price)}` : ''}`) : 'free';
|
|
184
|
+
return `${t.id.padEnd(36)} ${latest.padEnd(9)} ${t.framework.padEnd(13)} ${tier.padEnd(12)} ${t.apiAdd ? 'api add ✓' : 'api add ✗'} ${printable(t.title)}`;
|
|
185
|
+
}
|
|
186
|
+
async function runStarters(ctx) {
|
|
187
|
+
const { sub, flags, io } = ctx;
|
|
188
|
+
if (sub === 'cache') {
|
|
189
|
+
const dir = homePath('starters', 'cache');
|
|
190
|
+
const entries = [];
|
|
191
|
+
if (existsSync(dir))
|
|
192
|
+
for (const id of readdirSync(dir))
|
|
193
|
+
for (const v of readdirSync(resolve(dir, id)))
|
|
194
|
+
entries.push({ id, version: v });
|
|
195
|
+
if (flags.clear === true) {
|
|
196
|
+
removeHome('starters', 'cache');
|
|
197
|
+
io.print(entries.length ? `Removed ${entries.length} downloaded starter${entries.length === 1 ? '' : 's'}.` : 'The cache was already empty.');
|
|
198
|
+
io.result({ cleared: entries });
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
io.print(entries.length ? entries.map((e) => `${e.id} ${e.version}`).join('\n') : 'No starters downloaded yet.');
|
|
202
|
+
io.print(`\nCache: ${dir}`);
|
|
203
|
+
io.result({ cache: dir, entries });
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
const { index, warnings, fromCache } = await loadIndex({ refresh: flags.refresh === true, cached: flags.cached === true });
|
|
207
|
+
for (const w of warnings)
|
|
208
|
+
io.warn(w);
|
|
209
|
+
if (sub === 'show') {
|
|
210
|
+
const ref = ctx.positional[2];
|
|
211
|
+
if (!ref)
|
|
212
|
+
throw new ToolError('bad_args', 'Which starter? Usage: codz starters show <id>[@version]');
|
|
213
|
+
const r = resolveRef(index, ref);
|
|
214
|
+
for (const w of r.warnings)
|
|
215
|
+
io.warn(w);
|
|
216
|
+
const m = fromCache ? null : await fetchManifest(r.template, r.version);
|
|
217
|
+
const t = r.template;
|
|
218
|
+
io.print(`${t.id} ${r.version.version} ${printable(t.title)}`);
|
|
219
|
+
if (t.summary)
|
|
220
|
+
io.print(printable(t.summary));
|
|
221
|
+
io.print(`Framework: ${t.framework} Price: ${t.tier === 'paid' ? `paid${t.price ? ` (${printable(t.price)})` : ''}` : 'free'} api add: ${t.apiAdd ? 'supported' : 'not supported'}`);
|
|
222
|
+
if (t.provides)
|
|
223
|
+
io.print(`Provides: ${Object.entries(t.provides).map(([k, v]) => `${k} ${printable(v)}`).join(', ')}`);
|
|
224
|
+
if (t.extras?.length)
|
|
225
|
+
io.print(`Beyond codz create:\n${t.extras.map((e) => ` - ${printable(e)}`).join('\n')}`);
|
|
226
|
+
if (m) {
|
|
227
|
+
io.print(`Needs: ${Object.entries(m.requires).map(([k, v]) => `${k} ${v}`).join(', ') || 'nothing special'}`);
|
|
228
|
+
if (m.tested)
|
|
229
|
+
io.print(`Tested with: ${[m.tested.flutter?.length ? `Flutter ${m.tested.flutter.join(', ')}` : '', m.tested.node?.length ? `Node ${m.tested.node.join(', ')}` : ''].filter(Boolean).join('; ')}${m.tested.date ? ` on ${m.tested.date}` : ''}`);
|
|
230
|
+
io.print(`License: ${printable(m.license.kind)} Files: ${m.files.length} Questions: ${m.answers.map((a) => a.key).join(', ')}`);
|
|
231
|
+
}
|
|
232
|
+
io.print(`Versions: ${t.versions.map((v) => `${v.version}${v.yanked ? ' (replaced)' : ''}${index.revoked.some((x) => x.id === t.id && x.version === v.version) ? ' (withdrawn)' : ''}`).join(', ')}`);
|
|
233
|
+
io.print(`\nCreate a project: codz new <name> --template ${t.id}@${r.version.version}`);
|
|
234
|
+
io.result({ template: t, version: r.version, manifest: m });
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
if (sub && sub !== 'list')
|
|
238
|
+
throw new ToolError('bad_args', `Unknown command "starters ${sub}".`, 'Use: codz starters list | show <id> | cache');
|
|
239
|
+
let owned = new Set();
|
|
240
|
+
if (flags.owned === true)
|
|
241
|
+
owned = new Set((await auth.whoami()).owned.map((o) => o.id));
|
|
242
|
+
const f = (k) => str(flags, k);
|
|
243
|
+
const list = index.templates.filter((t) => (!f('framework') || t.framework === f('framework')) && ['state', 'router', 'http'].every((k) => !f(k) || t.provides?.[k] === f(k)) && (flags.free !== true || t.tier === 'free') && (flags.owned !== true || t.tier === 'free' || owned.has(t.id)));
|
|
244
|
+
io.print(list.length ? list.map((t) => line(t, latestUsable(t, index), owned)).join('\n') : 'No starters match.');
|
|
245
|
+
io.print(`\nDetails: codz starters show <id> Create: codz new <name> --template <id>`);
|
|
246
|
+
io.result({ templates: list.map((t) => ({ ...t, latest: latestUsable(t, index) })), catalog: { seq: index.seq, issued: index.issued, expires: index.expires, fromCache } });
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
// ---------- Entry ----------
|
|
250
|
+
export async function runStarterCommand(cmd, ctx) {
|
|
251
|
+
setAnnouncer((m) => ctx.io.warn(m));
|
|
252
|
+
switch (cmd) {
|
|
253
|
+
case 'new':
|
|
254
|
+
return runNew(ctx);
|
|
255
|
+
case 'starters':
|
|
256
|
+
return runStarters(ctx);
|
|
257
|
+
case 'login': {
|
|
258
|
+
if (process.env.CODZ_TOKEN)
|
|
259
|
+
ctx.io.warn('CODZ_TOKEN is set, so codz uses it instead of a login.');
|
|
260
|
+
const r = await auth.login({ print: (l) => ctx.io.print(l), browser: ctx.flags['no-browser'] !== true && !ctx.io.json });
|
|
261
|
+
ctx.io.print(`Logged in as ${r.account}.`);
|
|
262
|
+
ctx.io.result({ account: r.account });
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
case 'logout': {
|
|
266
|
+
const r = await auth.logout();
|
|
267
|
+
if (process.env.CODZ_TOKEN)
|
|
268
|
+
ctx.io.warn('CODZ_TOKEN is still set in this environment; unset it, and revoke it from your web account if it leaked.');
|
|
269
|
+
ctx.io.print(r.removed ? `Logged out${r.revoked ? '' : ' on this machine (the server could not be reached; revoke the session from your web account if needed)'}.` : 'You were not logged in.');
|
|
270
|
+
ctx.io.result({ ...r });
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
case 'whoami': {
|
|
274
|
+
const r = await auth.whoami();
|
|
275
|
+
ctx.io.print(`${r.account}`);
|
|
276
|
+
ctx.io.print(r.owned.length ? r.owned.map((o) => ` ${o.id}${o.updatesUntil ? ` (updates until ${o.updatesUntil})` : ''}`).join('\n') : ' No paid starters yet.');
|
|
277
|
+
ctx.io.result({ ...r });
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
throw new ToolError('bad_args', `Unknown command "${cmd}".`);
|
|
282
|
+
}
|
|
283
|
+
export { undoNew };
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// The starter engine: from a verified template and validated answers to a new
|
|
2
|
+
// project folder. Offline and network-free; it cannot tell whether the
|
|
3
|
+
// template came from the catalog, the cache or a folder on disk.
|
|
4
|
+
//
|
|
5
|
+
// Order: check requirements → build every file in memory (placeholders in
|
|
6
|
+
// contents and paths, paths checked again) → preview → make a temporary folder
|
|
7
|
+
// next to the target → run the before-move steps there (flutter create) →
|
|
8
|
+
// write the files and the dependency entries → rename into place in one step →
|
|
9
|
+
// run the after-move steps (pub get, format, npm install) → record history.
|
|
10
|
+
// The target never exists half-built: any failure before the rename removes
|
|
11
|
+
// the temporary folder.
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { appendHistory, newId, readHistory } from "../core/changeset.js";
|
|
16
|
+
import { hash } from "../core/fsutil.js";
|
|
17
|
+
import { jsonSet } from "../core/jsonEdit.js";
|
|
18
|
+
import { ToolError } from "../core/types.js";
|
|
19
|
+
import { placeholderValues, satisfies, STEP_ALLOWLIST } from "./manifest.js";
|
|
20
|
+
import { checkCollisions, checkRelPath } from "./paths.js";
|
|
21
|
+
import { runTool } from "../core/proc.js";
|
|
22
|
+
const BINARY_EXT = /\.(png|jpe?g|gif|webp|ico|icns|bmp|ttf|otf|woff2?|eot|jar|keystore|jks|zip|gz|pdf|mp3|mp4|wav|webm|riv|lottie)$/i;
|
|
23
|
+
export function isBinary(path, data) {
|
|
24
|
+
if (BINARY_EXT.test(path))
|
|
25
|
+
return true;
|
|
26
|
+
const n = Math.min(data.length, 8000);
|
|
27
|
+
for (let i = 0; i < n; i++)
|
|
28
|
+
if (data[i] === 0)
|
|
29
|
+
return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/** Plain string replacement of {{key}} for known keys only; anything else that looks like a placeholder is left alone. */
|
|
33
|
+
export function substitute(text, values) {
|
|
34
|
+
return text.replace(/\{\{([a-zA-Z][a-zA-Z0-9]*(?:\.path)?)\}\}/g, (m, key) => values.get(key) ?? m);
|
|
35
|
+
}
|
|
36
|
+
function run(cmd, args, cwd, timeout = 15 * 60_000) {
|
|
37
|
+
// flutter and npm are .bat/.cmd files on Windows, which Node only starts through the shell. Every argument
|
|
38
|
+
// here is a fixed word or an answer that passed the identifier / reverse-domain checks, so nothing a shell
|
|
39
|
+
// would interpret can reach it.
|
|
40
|
+
for (const a of args)
|
|
41
|
+
if (!/^[A-Za-z0-9._\-=/]+$/.test(a))
|
|
42
|
+
throw new ToolError('internal', `Refusing to run ${cmd} with the argument "${a}".`);
|
|
43
|
+
const r = runTool(cmd, args, { cwd, timeout });
|
|
44
|
+
const missing = r.error?.code === 'ENOENT' || (process.platform === 'win32' && r.status === 1 && /not recognized/i.test(r.stderr ?? ''));
|
|
45
|
+
return { ok: r.status === 0, missing, output: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
|
|
46
|
+
}
|
|
47
|
+
export function detectSdks(needFlutter) {
|
|
48
|
+
let flutter = null;
|
|
49
|
+
if (needFlutter) {
|
|
50
|
+
const r = run('flutter', ['--version', '--machine'], process.cwd(), 120_000);
|
|
51
|
+
if (r.ok) {
|
|
52
|
+
try {
|
|
53
|
+
flutter = JSON.parse(r.output.slice(r.output.indexOf('{'))).frameworkVersion ?? null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
flutter = /Flutter (\d+\.\d+\.\d+)/.exec(r.output)?.[1] ?? null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { flutter, node: process.version.replace(/^v/, '') };
|
|
61
|
+
}
|
|
62
|
+
// ---------- Planning ----------
|
|
63
|
+
function stepCommand(id, answers) {
|
|
64
|
+
switch (id) {
|
|
65
|
+
case 'flutter_create':
|
|
66
|
+
return ['flutter', 'create', '--no-pub', '--org', String(answers.org), '--project-name', String(answers.appName), '.'];
|
|
67
|
+
case 'flutter_pub_get':
|
|
68
|
+
return ['flutter', 'pub', 'get'];
|
|
69
|
+
case 'dart_format':
|
|
70
|
+
return ['dart', 'format', '.'];
|
|
71
|
+
case 'npm_install':
|
|
72
|
+
return [process.platform === 'win32' ? 'npm.cmd' : 'npm', 'install', '--ignore-scripts', '--no-audit', '--no-fund'];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** `sdks` null skips the SDK checks (publishing checks run without Flutter). */
|
|
76
|
+
export function planNew(t, answers, parent, codzVersion, sdkInfo, opts) {
|
|
77
|
+
const sdks = sdkInfo ?? { flutter: '0.0.0', node: process.version.replace(/^v/, '') };
|
|
78
|
+
const checkSdks = sdkInfo !== null;
|
|
79
|
+
const m = t.manifest;
|
|
80
|
+
const warnings = [];
|
|
81
|
+
const target = resolve(parent, String(answers.appName));
|
|
82
|
+
if (m.requires.codz && !satisfies(codzVersion, m.requires.codz))
|
|
83
|
+
throw new ToolError('template_incompatible', `${m.id} ${m.version} needs codz ${m.requires.codz}; this is codz ${codzVersion}.`, 'Update codz, or pick an older version of the template.');
|
|
84
|
+
if (checkSdks && m.requires.node && !satisfies(sdks.node, m.requires.node))
|
|
85
|
+
throw new ToolError('template_incompatible', `${m.id} ${m.version} needs Node ${m.requires.node}; this is Node ${sdks.node}.`, 'Install a matching Node version, or pick another template version.');
|
|
86
|
+
const usesFlutter = m.framework === 'flutter';
|
|
87
|
+
if (checkSdks && usesFlutter && m.steps.includes('flutter_create') && !sdks.flutter)
|
|
88
|
+
throw new ToolError('sdk_missing', 'Flutter was not found, and this template is added on top of flutter create.', 'Install Flutter (flutter.dev), check it with codz doctor --setup, then run this again.');
|
|
89
|
+
if (checkSdks && usesFlutter && sdks.flutter && m.requires.flutter && !satisfies(sdks.flutter, m.requires.flutter))
|
|
90
|
+
throw new ToolError('template_incompatible', `${m.id} ${m.version} needs Flutter ${m.requires.flutter}; this is Flutter ${sdks.flutter}.`, 'Switch Flutter versions (for example with fvm), or pick another template version.');
|
|
91
|
+
if (checkSdks && usesFlutter && sdks.flutter && m.tested?.flutter?.length && !m.tested.flutter.some((v) => sdks.flutter.startsWith(v)))
|
|
92
|
+
warnings.push(`This template was tested with Flutter ${m.tested.flutter.join(', ')}; you have ${sdks.flutter}.`);
|
|
93
|
+
// Files, with placeholders filled in, and their paths checked again.
|
|
94
|
+
const values = placeholderValues(m, answers);
|
|
95
|
+
const files = [];
|
|
96
|
+
for (const f of m.files) {
|
|
97
|
+
const data = t.files.get(f.path);
|
|
98
|
+
const path = checkRelPath(substitute(f.path, values), 'template (after filling in answers)');
|
|
99
|
+
const out = isBinary(f.path, data) ? data : Buffer.from(substitute(data.toString('utf8'), values), 'utf8');
|
|
100
|
+
files.push({ path, data: out, mode: f.mode ?? '644' });
|
|
101
|
+
}
|
|
102
|
+
checkCollisions(files.map((f) => f.path), 'template (after filling in answers)');
|
|
103
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
104
|
+
const steps = m.steps
|
|
105
|
+
.filter((id) => opts.steps || STEP_ALLOWLIST[id].when === 'before-move')
|
|
106
|
+
.map((id) => ({ id, label: STEP_ALLOWLIST[id].label, when: STEP_ALLOWLIST[id].when, command: stepCommand(id, answers) }));
|
|
107
|
+
if (checkSdks && usesFlutter && !sdks.flutter && steps.some((s) => s.id === 'flutter_pub_get' || s.id === 'dart_format'))
|
|
108
|
+
warnings.push('Flutter was not found, so flutter pub get and dart format will be skipped; run them yourself.');
|
|
109
|
+
return {
|
|
110
|
+
template: { id: m.id, version: m.version, title: m.title, origin: t.origin, archiveSha256: t.archiveSha256, manifestSha256: hash(t.manifestBytes.toString('utf8')), license: m.license.kind },
|
|
111
|
+
target,
|
|
112
|
+
answers,
|
|
113
|
+
files,
|
|
114
|
+
steps,
|
|
115
|
+
dependencies: m.dependencies ?? {},
|
|
116
|
+
devDependencies: m.devDependencies ?? {},
|
|
117
|
+
warnings,
|
|
118
|
+
apiAdd: m.apiAdd,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** The preview: a folder tree of every file, the steps and the template identity. */
|
|
122
|
+
export function renderPlan(p) {
|
|
123
|
+
const lines = [
|
|
124
|
+
`Template: ${p.template.id} ${p.template.version}${p.template.archiveSha256 ? ` (verified, archive ${p.template.archiveSha256.slice(0, 12)})` : ` (from ${p.template.origin}, files verified)`}`,
|
|
125
|
+
`License: ${p.template.license}`,
|
|
126
|
+
`Creates ${p.files.length} files in ${p.target}`,
|
|
127
|
+
'',
|
|
128
|
+
];
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const f of p.files) {
|
|
131
|
+
const segs = f.path.split('/');
|
|
132
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
133
|
+
const d = segs.slice(0, i + 1).join('/');
|
|
134
|
+
if (!seen.has(d)) {
|
|
135
|
+
seen.add(d);
|
|
136
|
+
lines.push(`${' '.repeat(i)}${segs[i]}/`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
lines.push(`${' '.repeat(segs.length - 1)}${segs[segs.length - 1]}${f.mode === '755' ? ' (executable)' : ''}`);
|
|
140
|
+
}
|
|
141
|
+
const deps = Object.entries(p.dependencies);
|
|
142
|
+
if (deps.length)
|
|
143
|
+
lines.push('', `Dependencies added: ${deps.map(([k, v]) => `${k} ${v}`).join(', ')}`);
|
|
144
|
+
if (p.steps.length)
|
|
145
|
+
lines.push('', 'Steps:', ...p.steps.map((s) => ` ${s.when === 'before-move' ? 'before' : 'after'}: ${s.command.join(' ')}`));
|
|
146
|
+
if (!p.apiAdd.supported)
|
|
147
|
+
lines.push('', `Note: codz api add does not support this starter${p.apiAdd.note ? ` (${p.apiAdd.note})` : ''}.`);
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
// ---------- Writing ----------
|
|
151
|
+
/** Adds dependencies under `dependencies:` (or `dev_dependencies:`) in pubspec.yaml, after the SDK entries. */
|
|
152
|
+
export function addPubspecEntries(text, section, deps) {
|
|
153
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
154
|
+
const have = new Set();
|
|
155
|
+
let idx = lines.findIndex((l) => new RegExp(`^${section}:\\s*$`).test(l));
|
|
156
|
+
if (idx !== -1)
|
|
157
|
+
for (let i = idx + 1; i < lines.length && (/^\s/.test(lines[i]) || !lines[i].trim()); i++) {
|
|
158
|
+
const k = /^ {2}([A-Za-z0-9_]+):/.exec(lines[i])?.[1];
|
|
159
|
+
if (k)
|
|
160
|
+
have.add(k);
|
|
161
|
+
}
|
|
162
|
+
const missing = Object.keys(deps).filter((k) => !have.has(k)).sort();
|
|
163
|
+
if (!missing.length)
|
|
164
|
+
return text;
|
|
165
|
+
const entries = missing.map((k) => ` ${k}: ${/^[\^~<>=0-9]/.test(deps[k]) && !/[:#]/.test(deps[k]) ? deps[k] : JSON.stringify(deps[k])}`);
|
|
166
|
+
if (idx === -1) {
|
|
167
|
+
lines.push(...(lines[lines.length - 1] === '' ? [] : ['']), `${section}:`, ...entries, '');
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
let at = idx + 1;
|
|
171
|
+
while (/^ {2}\w+:\s*$/.test(lines[at] ?? '') && /^ {4}sdk:/.test(lines[at + 1] ?? ''))
|
|
172
|
+
at += 2;
|
|
173
|
+
lines.splice(at, 0, ...entries);
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
function addPackageJsonEntries(text, section, deps) {
|
|
177
|
+
let t = text;
|
|
178
|
+
const existing = JSON.parse(text)[section] ?? {};
|
|
179
|
+
for (const k of Object.keys(deps).sort())
|
|
180
|
+
if (!(k in existing))
|
|
181
|
+
t = jsonSet(t, [section, k], deps[k]);
|
|
182
|
+
return t;
|
|
183
|
+
}
|
|
184
|
+
/** Files that steps and later commands create and that never block undo. */
|
|
185
|
+
const GENERATED = [/^\.dart_tool(\/|$)/, /^build(\/|$)/, /^\.flutter-plugins(-dependencies)?$/, /^pubspec\.lock$/, /^node_modules(\/|$)/, /^package-lock\.json$/, /^\.next(\/|$)/, /^\.codz(\/|$)/, /^codz\.config\.yaml$/, /^\.idea(\/|$)/, /^ios\/Pods(\/|$)/, /^ios\/Podfile\.lock$/, /^\.packages$/, /^next-env\.d\.ts$/, /^android\/local\.properties$/, /^android\/\.gradle(\/|$)/, /^\.metadata$/, /^.*\.iml$/];
|
|
186
|
+
function listFiles(root, rel = '') {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const name of readdirSync(join(root, rel)).sort()) {
|
|
189
|
+
const p = rel ? `${rel}/${name}` : name;
|
|
190
|
+
if (GENERATED.some((g) => g.test(p)))
|
|
191
|
+
continue;
|
|
192
|
+
const st = lstatSync(join(root, p));
|
|
193
|
+
if (st.isDirectory())
|
|
194
|
+
out.push(...listFiles(root, p));
|
|
195
|
+
else if (st.isFile())
|
|
196
|
+
out.push(p);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
export function applyNew(p, log = () => { }) {
|
|
201
|
+
if (existsSync(p.target) && readdirSync(p.target).length)
|
|
202
|
+
throw new ToolError('dir_not_empty', `${p.target} already exists and is not empty.`, 'Pick another name, or remove that folder.');
|
|
203
|
+
const parent = dirname(p.target);
|
|
204
|
+
mkdirSync(parent, { recursive: true });
|
|
205
|
+
const tmp = join(parent, `.${basename(p.target)}.codz-new-${randomBytes(4).toString('hex')}`);
|
|
206
|
+
mkdirSync(tmp);
|
|
207
|
+
const warnings = [...p.warnings];
|
|
208
|
+
const stepResults = [];
|
|
209
|
+
try {
|
|
210
|
+
for (const s of p.steps.filter((x) => x.when === 'before-move')) {
|
|
211
|
+
log(`Running ${s.command.join(' ')}...`);
|
|
212
|
+
const r = run(s.command[0], s.command.slice(1), tmp);
|
|
213
|
+
if (!r.ok)
|
|
214
|
+
throw new ToolError(r.missing ? 'sdk_missing' : 'step_failed', `${s.command.join(' ')} ${r.missing ? 'was not found' : 'failed'}, so nothing was created.${r.output ? `\n${r.output.split('\n').slice(-5).join('\n')}` : ''}`, r.missing ? 'Install the SDK; check it with codz doctor --setup.' : 'Fix the problem above and run this again.');
|
|
215
|
+
stepResults.push({ id: s.id, ok: true });
|
|
216
|
+
}
|
|
217
|
+
for (const f of p.files) {
|
|
218
|
+
const abs = join(tmp, f.path);
|
|
219
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
220
|
+
writeFileSync(abs, f.data);
|
|
221
|
+
if (f.mode === '755')
|
|
222
|
+
chmodSync(abs, 0o755);
|
|
223
|
+
}
|
|
224
|
+
const deps = p.dependencies;
|
|
225
|
+
const dev = p.devDependencies;
|
|
226
|
+
if (Object.keys(deps).length || Object.keys(dev).length) {
|
|
227
|
+
const pubspec = join(tmp, 'pubspec.yaml');
|
|
228
|
+
const pkg = join(tmp, 'package.json');
|
|
229
|
+
if (existsSync(pubspec)) {
|
|
230
|
+
let t = readFileSync(pubspec, 'utf8');
|
|
231
|
+
t = addPubspecEntries(t, 'dependencies', deps);
|
|
232
|
+
t = addPubspecEntries(t, 'dev_dependencies', dev);
|
|
233
|
+
writeFileSync(pubspec, t);
|
|
234
|
+
}
|
|
235
|
+
else if (existsSync(pkg)) {
|
|
236
|
+
let t = readFileSync(pkg, 'utf8');
|
|
237
|
+
t = addPackageJsonEntries(t, 'dependencies', deps);
|
|
238
|
+
t = addPackageJsonEntries(t, 'devDependencies', dev);
|
|
239
|
+
writeFileSync(pkg, t);
|
|
240
|
+
}
|
|
241
|
+
else
|
|
242
|
+
warnings.push('The template lists dependencies but has no pubspec.yaml or package.json to add them to.');
|
|
243
|
+
}
|
|
244
|
+
if (existsSync(p.target))
|
|
245
|
+
rmSync(p.target, { recursive: true, force: true }); // an empty folder only (checked above)
|
|
246
|
+
renameSync(tmp, p.target);
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
for (const s of p.steps.filter((x) => x.when === 'after-move')) {
|
|
253
|
+
log(`Running ${s.command.join(' ')}...`);
|
|
254
|
+
const r = run(s.command[0], s.command.slice(1), p.target);
|
|
255
|
+
if (r.ok)
|
|
256
|
+
stepResults.push({ id: s.id, ok: true });
|
|
257
|
+
else {
|
|
258
|
+
const note = `${s.command.join(' ')} ${r.missing ? 'was not found' : 'failed'}; the project was created. Run it yourself in ${basename(p.target)}/.`;
|
|
259
|
+
stepResults.push({ id: s.id, ok: false, note });
|
|
260
|
+
warnings.push(note);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// History in the new project, so codz undo there removes it again.
|
|
264
|
+
const id = newId();
|
|
265
|
+
const recorded = listFiles(p.target);
|
|
266
|
+
appendHistory(p.target, {
|
|
267
|
+
id,
|
|
268
|
+
skill: 'new',
|
|
269
|
+
summary: [`Create ${basename(p.target)} from ${p.template.id} ${p.template.version}`],
|
|
270
|
+
files: recorded.map((f) => ({ path: f, created: true, afterHash: hash(readFileSync(join(p.target, f), 'utf8')) })),
|
|
271
|
+
generated: [],
|
|
272
|
+
undone: false,
|
|
273
|
+
meta: { starter: { id: p.template.id, version: p.template.version, archiveSha256: p.template.archiveSha256 ?? null, manifestSha256: p.template.manifestSha256, origin: p.template.archiveSha256 ? 'catalog' : 'folder' }, answers: p.answers, target: p.target },
|
|
274
|
+
});
|
|
275
|
+
return { id, target: p.target, files: recorded.length, steps: stepResults, warnings };
|
|
276
|
+
}
|
|
277
|
+
// ---------- Undo ----------
|
|
278
|
+
/** The last change in this project, when it is the codz new that created it. */
|
|
279
|
+
export function lastNewEntry(root) {
|
|
280
|
+
const e = [...readHistory(root)].reverse().find((x) => !x.undone);
|
|
281
|
+
return e && e.skill === 'new' ? e : null;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Removes a project codz new created, when nothing in it changed since.
|
|
285
|
+
* Generated files (pub get, npm install, codz init) do not count as changes;
|
|
286
|
+
* any other edit or new file blocks it unless `force`.
|
|
287
|
+
*/
|
|
288
|
+
export function undoNew(root, opts = {}) {
|
|
289
|
+
const entry = lastNewEntry(root);
|
|
290
|
+
if (!entry)
|
|
291
|
+
throw new ToolError('nothing_to_undo', 'The last change here was not codz new.');
|
|
292
|
+
const target = resolve(String(entry.meta?.target ?? ''));
|
|
293
|
+
if (target !== resolve(root))
|
|
294
|
+
throw new ToolError('changed_since_apply', `This project was created at ${target}, not here, so codz will not delete it.`, 'Delete the folder yourself if you no longer want it.');
|
|
295
|
+
const recorded = new Map(entry.files.map((f) => [f.path, f.afterHash]));
|
|
296
|
+
const now = listFiles(root);
|
|
297
|
+
const changed = [
|
|
298
|
+
...entry.files.filter((f) => !existsSync(join(root, f.path)) || hash(readFileSync(join(root, f.path), 'utf8')) !== f.afterHash).map((f) => f.path),
|
|
299
|
+
...now.filter((f) => !recorded.has(f) && !(f === '.gitignore')),
|
|
300
|
+
];
|
|
301
|
+
// codz init adds .codz/ to .gitignore; that alone does not count.
|
|
302
|
+
const gi = recorded.get('.gitignore');
|
|
303
|
+
const idx = changed.indexOf('.gitignore');
|
|
304
|
+
if (idx !== -1 && gi && existsSync(join(root, '.gitignore'))) {
|
|
305
|
+
const t = readFileSync(join(root, '.gitignore'), 'utf8');
|
|
306
|
+
const without = t.replace(/\n?# codz cache, backups and history\n\.codz\/\n$/, '');
|
|
307
|
+
if (hash(without) === gi || hash(`${without}\n`) === gi)
|
|
308
|
+
changed.splice(idx, 1);
|
|
309
|
+
}
|
|
310
|
+
if (changed.length && !opts.force) {
|
|
311
|
+
throw new ToolError('changed_since_apply', `These files changed after codz created the project: ${changed.slice(0, 8).join(', ')}${changed.length > 8 ? ', ...' : ''}. Undo would delete those edits.`, 'Keep your edits, or pass --force to delete the whole project anyway.');
|
|
312
|
+
}
|
|
313
|
+
rmSync(root, { recursive: true, force: true });
|
|
314
|
+
return { id: entry.id, removed: root };
|
|
315
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// codz's per-user folder (~/.codz, or CODZ_HOME): the login token,
|
|
2
|
+
// the last signed catalog index and the cache of verified starter archives.
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
export function codzHome() {
|
|
7
|
+
return process.env.CODZ_HOME || join(homedir(), '.codz');
|
|
8
|
+
}
|
|
9
|
+
export function homePath(...parts) {
|
|
10
|
+
return join(codzHome(), ...parts);
|
|
11
|
+
}
|
|
12
|
+
export function readHomeFile(...parts) {
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(homePath(...parts));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Writes atomically (temporary name, then rename). `secret` files are readable by the owner only. */
|
|
21
|
+
export function writeHomeFile(data, opts, ...parts) {
|
|
22
|
+
const path = homePath(...parts);
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
24
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
25
|
+
writeFileSync(tmp, data, { mode: opts.secret ? 0o600 : 0o644 });
|
|
26
|
+
if (opts.secret) {
|
|
27
|
+
try {
|
|
28
|
+
chmodSync(tmp, 0o600);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Windows has no POSIX modes; the file is in the user's profile folder.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
renameSync(tmp, path);
|
|
35
|
+
}
|
|
36
|
+
export function removeHome(...parts) {
|
|
37
|
+
const path = homePath(...parts);
|
|
38
|
+
if (!existsSync(path))
|
|
39
|
+
return false;
|
|
40
|
+
rmSync(path, { recursive: true, force: true });
|
|
41
|
+
return true;
|
|
42
|
+
}
|