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.
Files changed (118) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +525 -2
  4. package/assets/gradle-wrapper/README.txt +2 -0
  5. package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
  6. package/assets/gradle-wrapper/gradlew +251 -0
  7. package/assets/gradle-wrapper/gradlew.bat +94 -0
  8. package/bin/codz.js +33 -0
  9. package/dist/adapters/android.js +321 -0
  10. package/dist/adapters/androidSetup.js +277 -0
  11. package/dist/adapters/common.js +114 -0
  12. package/dist/adapters/dartGen.js +83 -0
  13. package/dist/adapters/flutter.js +291 -0
  14. package/dist/adapters/flutterGen.js +204 -0
  15. package/dist/adapters/flutterScan.js +312 -0
  16. package/dist/adapters/flutterSetup.js +252 -0
  17. package/dist/adapters/flutterStyles.js +130 -0
  18. package/dist/adapters/generic.js +20 -0
  19. package/dist/adapters/getx.js +423 -0
  20. package/dist/adapters/index.js +28 -0
  21. package/dist/adapters/ios.js +440 -0
  22. package/dist/adapters/kotlinGen.js +110 -0
  23. package/dist/adapters/models.js +116 -0
  24. package/dist/adapters/nextjs.js +243 -0
  25. package/dist/adapters/nextjsGen.js +285 -0
  26. package/dist/adapters/nextjsScan.js +364 -0
  27. package/dist/adapters/nextjsSetup.js +119 -0
  28. package/dist/adapters/react.js +89 -0
  29. package/dist/adapters/reactNative.js +109 -0
  30. package/dist/adapters/reactNativeSetup.js +236 -0
  31. package/dist/adapters/reactQuery.js +89 -0
  32. package/dist/adapters/reactSetup.js +150 -0
  33. package/dist/adapters/source.js +165 -0
  34. package/dist/cli.js +1175 -0
  35. package/dist/core/cache.js +76 -0
  36. package/dist/core/changeset.js +404 -0
  37. package/dist/core/commands.js +239 -0
  38. package/dist/core/config.js +45 -0
  39. package/dist/core/diff.js +143 -0
  40. package/dist/core/events.js +41 -0
  41. package/dist/core/fsutil.js +122 -0
  42. package/dist/core/git.js +36 -0
  43. package/dist/core/jsonEdit.js +113 -0
  44. package/dist/core/merge.js +159 -0
  45. package/dist/core/proc.js +23 -0
  46. package/dist/core/safe.js +75 -0
  47. package/dist/core/templates.js +100 -0
  48. package/dist/core/tsAst.js +112 -0
  49. package/dist/core/types.js +12 -0
  50. package/dist/core/validateConfig.js +172 -0
  51. package/dist/core/workflow.js +72 -0
  52. package/dist/core/workspace.js +80 -0
  53. package/dist/core/yaml.js +126 -0
  54. package/dist/core/yamlDoc.js +367 -0
  55. package/dist/detector.js +95 -0
  56. package/dist/input/apiSpec.js +395 -0
  57. package/dist/input/curl.js +259 -0
  58. package/dist/input/endpoint.js +220 -0
  59. package/dist/input/har.js +120 -0
  60. package/dist/input/infer.js +174 -0
  61. package/dist/input/naming.js +145 -0
  62. package/dist/input/paste.js +130 -0
  63. package/dist/skills/androidSkeleton.js +292 -0
  64. package/dist/skills/androidTests.js +221 -0
  65. package/dist/skills/androidUi.js +561 -0
  66. package/dist/skills/androidViews.js +536 -0
  67. package/dist/skills/apiAdd.js +99 -0
  68. package/dist/skills/apiDiff.js +271 -0
  69. package/dist/skills/apiEdit.js +547 -0
  70. package/dist/skills/apiImport.js +127 -0
  71. package/dist/skills/apiList.js +35 -0
  72. package/dist/skills/apiSync.js +221 -0
  73. package/dist/skills/auth.js +1304 -0
  74. package/dist/skills/completion.js +142 -0
  75. package/dist/skills/config.js +93 -0
  76. package/dist/skills/create.js +638 -0
  77. package/dist/skills/createDemo.js +1026 -0
  78. package/dist/skills/doctor.js +304 -0
  79. package/dist/skills/doctorFix.js +214 -0
  80. package/dist/skills/explain.js +61 -0
  81. package/dist/skills/feature.js +418 -0
  82. package/dist/skills/featureEdit.js +225 -0
  83. package/dist/skills/featureLinks.js +179 -0
  84. package/dist/skills/init.js +66 -0
  85. package/dist/skills/iosUi.js +468 -0
  86. package/dist/skills/locale.js +335 -0
  87. package/dist/skills/mcp.js +175 -0
  88. package/dist/skills/mock.js +482 -0
  89. package/dist/skills/module.js +23 -0
  90. package/dist/skills/plans.js +66 -0
  91. package/dist/skills/prefill.js +84 -0
  92. package/dist/skills/reactNativeUi.js +388 -0
  93. package/dist/skills/reactUi.js +352 -0
  94. package/dist/skills/report.js +75 -0
  95. package/dist/skills/route.js +230 -0
  96. package/dist/skills/screen.js +597 -0
  97. package/dist/skills/screenForm.js +410 -0
  98. package/dist/skills/setup.js +28 -0
  99. package/dist/skills/setupDoctor.js +226 -0
  100. package/dist/skills/templatesCmd.js +32 -0
  101. package/dist/skills/testgen.js +331 -0
  102. package/dist/skills/theme.js +388 -0
  103. package/dist/starters/auth.js +144 -0
  104. package/dist/starters/commands.js +283 -0
  105. package/dist/starters/engine.js +315 -0
  106. package/dist/starters/home.js +42 -0
  107. package/dist/starters/http.js +93 -0
  108. package/dist/starters/manifest.js +285 -0
  109. package/dist/starters/paths.js +70 -0
  110. package/dist/starters/publish.js +150 -0
  111. package/dist/starters/registry.js +258 -0
  112. package/dist/starters/sign.js +72 -0
  113. package/dist/starters/source.js +98 -0
  114. package/dist/starters/strictJson.js +148 -0
  115. package/dist/starters/tar.js +180 -0
  116. package/docs/COMMANDS.md +527 -0
  117. package/examples/openapi.yaml +319 -0
  118. package/package.json +57 -4
@@ -0,0 +1,93 @@
1
+ // The only network code in codz. Every request goes to the catalog or its
2
+ // API, announces itself once per run, has a timeout and a size cap, and maps
3
+ // failures to codz error codes. Tokens go in the Authorization header and
4
+ // are never part of a message.
5
+ import { ToolError } from "../core/types.js";
6
+ let announced = false;
7
+ let announcer = (m) => process.stderr.write(`${m}\n`);
8
+ export function setAnnouncer(fn) {
9
+ announcer = fn;
10
+ announced = false;
11
+ }
12
+ function announce(url) {
13
+ if (announced)
14
+ return;
15
+ announced = true;
16
+ announcer(`Contacting the starter catalog at ${url.host} (codz new and starters commands only; nothing about your projects is sent).`);
17
+ }
18
+ /** https only, except a local test server. */
19
+ export function checkUrl(raw, what) {
20
+ let u;
21
+ try {
22
+ u = new URL(raw);
23
+ }
24
+ catch {
25
+ throw new ToolError('bad_registry', `${what} is not a URL: ${raw.slice(0, 100)}`);
26
+ }
27
+ const local = u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '[::1]';
28
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local))
29
+ throw new ToolError('bad_registry', `${what} must use https: ${u.origin}`);
30
+ if (u.username || u.password)
31
+ throw new ToolError('bad_registry', `${what} must not contain a user name or password.`);
32
+ return u;
33
+ }
34
+ export async function request(url, opts) {
35
+ announce(url);
36
+ const headers = { 'user-agent': 'codz', accept: 'application/json, application/octet-stream' };
37
+ if (opts.json !== undefined)
38
+ headers['content-type'] = 'application/json';
39
+ if (opts.token)
40
+ headers.authorization = `Bearer ${opts.token}`;
41
+ if (opts.etag)
42
+ headers['if-none-match'] = opts.etag;
43
+ let res;
44
+ try {
45
+ res = await fetch(url, { method: opts.method ?? 'GET', headers, body: opts.json === undefined ? undefined : JSON.stringify(opts.json), redirect: 'error', signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000) });
46
+ }
47
+ catch (e) {
48
+ const timeout = e.name === 'TimeoutError';
49
+ throw new ToolError('network_unavailable', `Could not reach the starter catalog (${url.host}): ${timeout ? 'timed out' : 'connection failed'}.`, 'Check the connection, or use a starter that is already downloaded with --cached.');
50
+ }
51
+ const len = Number(res.headers.get('content-length') ?? '0');
52
+ if (len > opts.maxBytes)
53
+ throw new ToolError('archive_too_large', `The response from ${url.host} is larger than the safety limit.`, 'Report it; nothing was written.');
54
+ // Read with a running cap, so a server that lies about the length cannot fill memory.
55
+ const chunks = [];
56
+ let total = 0;
57
+ if (res.body) {
58
+ const reader = res.body.getReader();
59
+ for (;;) {
60
+ const { done, value } = await reader.read();
61
+ if (done)
62
+ break;
63
+ total += value.length;
64
+ if (total > opts.maxBytes) {
65
+ await reader.cancel();
66
+ throw new ToolError('archive_too_large', `The response from ${url.host} is larger than the safety limit.`, 'Report it; nothing was written.');
67
+ }
68
+ chunks.push(Buffer.from(value));
69
+ }
70
+ }
71
+ return { status: res.status, body: Buffer.concat(chunks), headers: res.headers };
72
+ }
73
+ /** Turns a failed API answer into the matching codz error. */
74
+ export function apiError(r, ctx) {
75
+ let code = '';
76
+ try {
77
+ code = String(JSON.parse(r.body.toString('utf8')).error ?? '');
78
+ }
79
+ catch {
80
+ // Not JSON.
81
+ }
82
+ if (r.status === 401)
83
+ return ctx.hadToken ? new ToolError('token_expired', 'Your session has expired.', 'Run codz login again.') : new ToolError('not_logged_in', `${ctx.what} needs an account.`, 'Run codz login.');
84
+ if (r.status === 403)
85
+ return new ToolError('no_entitlement', `This account does not own ${ctx.what}.`, 'Buy it in the gallery, then run codz whoami to refresh.');
86
+ if (r.status === 404)
87
+ return new ToolError(code === 'version_not_found' ? 'version_not_found' : 'template_not_found', `${ctx.what} was not found in the catalog.`, 'Check the id with codz starters list.');
88
+ if (r.status === 429) {
89
+ const after = r.headers.get('retry-after');
90
+ return new ToolError('rate_limited', 'Too many download requests.', `Wait${after ? ` ${after} seconds` : ' a while'} and try again.`);
91
+ }
92
+ return new ToolError('network_unavailable', `The starter catalog answered ${r.status} for ${ctx.what}.`, 'Try again later; report it if it persists.');
93
+ }
@@ -0,0 +1,285 @@
1
+ // The starter manifest: what a template is, what it asks, which allowlisted
2
+ // steps it needs and the hash of every file. It is data only; nothing in it is
3
+ // ever run. Unknown top-level fields are refused so a newer, stricter format
4
+ // cannot be half-understood by an older codz.
5
+ import { ToolError } from "../core/types.js";
6
+ import { parseStrictJson } from "./strictJson.js";
7
+ import { checkCollisions, checkRelPath, LIMITS } from "./paths.js";
8
+ export const SCHEMA = 1;
9
+ /** Which steps each framework may use. Nothing outside this table can ever run. */
10
+ export const STEP_ALLOWLIST = {
11
+ flutter_create: { frameworks: ['flutter'], when: 'before-move', label: 'flutter create (the platform folders)' },
12
+ flutter_pub_get: { frameworks: ['flutter'], when: 'after-move', label: 'flutter pub get' },
13
+ dart_format: { frameworks: ['flutter'], when: 'after-move', label: 'dart format .' },
14
+ npm_install: { frameworks: ['nextjs', 'react-native'], when: 'after-move', label: 'npm install --ignore-scripts (no package install scripts run)' },
15
+ };
16
+ const TOP_KEYS = new Set(['schema', 'id', 'version', 'title', 'summary', 'framework', 'mode', 'requires', 'tested', 'provides', 'extras', 'apiAdd', 'answers', 'dependencies', 'devDependencies', 'steps', 'expectedConventions', 'files', 'license']);
17
+ export const ID_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
18
+ export const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
19
+ const KEY_RE = /^[a-zA-Z][a-zA-Z0-9]*$/;
20
+ const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
21
+ const RANGE_PART = /^(\^|~|>=|<=|>|<|=)?\d+(\.\d+){0,2}$/;
22
+ const bad = (msg) => {
23
+ throw new ToolError('bad_template', `The manifest is not valid: ${msg}.`, 'Report it; the template is not usable as published.');
24
+ };
25
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
26
+ const str = (v, name, max = 200) => {
27
+ if (typeof v !== 'string' || !v || v.length > max)
28
+ bad(`${name} must be text of 1-${max} characters`);
29
+ return v;
30
+ };
31
+ const strMap = (v, name, keyRe, max = 100) => {
32
+ if (v === undefined)
33
+ return {};
34
+ if (!isObj(v))
35
+ bad(`${name} must be an object`);
36
+ const out = {};
37
+ for (const [k, val] of Object.entries(v)) {
38
+ if (!keyRe.test(k))
39
+ bad(`${name} has a bad key "${k.slice(0, 60)}"`);
40
+ out[k] = str(val, `${name}.${k}`, max);
41
+ }
42
+ return out;
43
+ };
44
+ export function parseManifest(text) {
45
+ const raw = parseStrictJson(text, 'manifest.json', { maxBytes: 4 * 1024 * 1024, maxString: 4096 });
46
+ if (!isObj(raw))
47
+ return bad('it must be a JSON object');
48
+ const o = raw;
49
+ for (const k of Object.keys(o))
50
+ if (!TOP_KEYS.has(k))
51
+ bad(`unknown field "${k.slice(0, 60)}" (a newer codz may be needed)`);
52
+ if (o.schema !== SCHEMA)
53
+ throw new ToolError('template_incompatible', `This template uses manifest schema ${String(o.schema)}; this codz reads schema ${SCHEMA}.`, 'Update codz, or pick an older version of the template.');
54
+ const id = str(o.id, 'id', 64);
55
+ if (!ID_RE.test(id))
56
+ bad('id must be lowercase letters, digits and dashes');
57
+ const version = str(o.version, 'version', 32);
58
+ if (!VERSION_RE.test(version))
59
+ bad('version must be x.y.z');
60
+ const framework = o.framework;
61
+ if (!['flutter', 'nextjs', 'react-native', 'android'].includes(framework))
62
+ bad('framework must be flutter, nextjs, react-native or android');
63
+ const mode = o.mode;
64
+ if (mode !== 'overlay' && mode !== 'full')
65
+ bad('mode must be overlay or full');
66
+ if (mode === 'overlay' && framework !== 'flutter')
67
+ bad('overlay mode is only for Flutter (on flutter create)');
68
+ if (!isObj(o.requires))
69
+ bad('requires must be an object');
70
+ const requires = strMap(o.requires, 'requires', /^(codz|flutter|node)$/);
71
+ for (const [k, r] of Object.entries(requires))
72
+ if (!r.split(/\s+/).every((p) => RANGE_PART.test(p)))
73
+ bad(`requires.${k} is not a version range`);
74
+ let tested;
75
+ if (o.tested !== undefined) {
76
+ if (!isObj(o.tested))
77
+ bad('tested must be an object');
78
+ const t = o.tested;
79
+ for (const k of Object.keys(t))
80
+ if (!['flutter', 'node', 'date'].includes(k))
81
+ bad(`tested has unknown field "${k.slice(0, 40)}"`);
82
+ const list = (v, n) => {
83
+ if (v === undefined)
84
+ return undefined;
85
+ if (!Array.isArray(v) || v.length > 20)
86
+ bad(`tested.${n} must be a short list`);
87
+ return v.map((x) => str(x, `tested.${n}`, 20));
88
+ };
89
+ tested = { flutter: list(t.flutter, 'flutter'), node: list(t.node, 'node'), date: t.date === undefined ? undefined : str(t.date, 'tested.date', 10) };
90
+ }
91
+ if (!isObj(o.apiAdd) || typeof o.apiAdd.supported !== 'boolean')
92
+ bad('apiAdd.supported must be true or false');
93
+ const apiAddRaw = o.apiAdd;
94
+ const apiAdd = { supported: apiAddRaw.supported, note: apiAddRaw.note === undefined ? undefined : str(apiAddRaw.note, 'apiAdd.note', 300) };
95
+ if (!Array.isArray(o.answers) || o.answers.length > 20)
96
+ bad('answers must be a list of at most 20 questions');
97
+ const answers = o.answers.map((a, n) => {
98
+ if (!isObj(a))
99
+ return bad(`answers[${n}] must be an object`);
100
+ const r = a;
101
+ for (const k of Object.keys(r))
102
+ if (!['key', 'prompt', 'type', 'default', 'choices', 'maxLength'].includes(k))
103
+ bad(`answers[${n}] has unknown field "${k.slice(0, 40)}"`);
104
+ const key = str(r.key, `answers[${n}].key`, 40);
105
+ if (!KEY_RE.test(key))
106
+ bad(`answers[${n}].key must be letters and digits`);
107
+ const type = r.type;
108
+ if (!['identifier', 'npm-name', 'reverse-domain', 'string', 'choice', 'boolean'].includes(type))
109
+ bad(`answers[${n}].type is not a known answer type`);
110
+ const choices = r.choices === undefined ? undefined : Array.isArray(r.choices) && r.choices.length && r.choices.length <= 30 ? r.choices.map((c) => str(c, `answers[${n}].choices`, 60)) : bad(`answers[${n}].choices must be a short list`);
111
+ if (type === 'choice' && !choices)
112
+ bad(`answers[${n}] is a choice without choices`);
113
+ const def = r.default;
114
+ if (def !== undefined && typeof def !== 'string' && typeof def !== 'boolean')
115
+ bad(`answers[${n}].default must be text or true/false`);
116
+ const maxLength = r.maxLength === undefined ? undefined : typeof r.maxLength === 'number' && r.maxLength > 0 && r.maxLength <= 500 ? r.maxLength : bad(`answers[${n}].maxLength must be 1-500`);
117
+ return { key, prompt: str(r.prompt, `answers[${n}].prompt`, 120), type, default: def, choices, maxLength };
118
+ });
119
+ if (new Set(answers.map((a) => a.key)).size !== answers.length)
120
+ bad('two answers use the same key');
121
+ const appName = answers.find((a) => a.key === 'appName');
122
+ if (!appName)
123
+ bad('it needs an appName answer');
124
+ if (framework === 'flutter' && appName.type !== 'identifier')
125
+ bad('a Flutter appName must be an identifier answer');
126
+ if (mode === 'overlay' && answers.find((a) => a.key === 'org')?.type !== 'reverse-domain')
127
+ bad('overlay mode needs an org answer of type reverse-domain (for flutter create --org)');
128
+ if (!Array.isArray(o.steps))
129
+ bad('steps must be a list');
130
+ const steps = o.steps.map((s) => {
131
+ if (typeof s !== 'string' || !(s in STEP_ALLOWLIST))
132
+ bad(`step "${String(s).slice(0, 40)}" is not on codz's allowlist (${Object.keys(STEP_ALLOWLIST).join(', ')})`);
133
+ if (!STEP_ALLOWLIST[s].frameworks.includes(framework))
134
+ bad(`step ${s} is not allowed for ${framework}`);
135
+ return s;
136
+ });
137
+ if (new Set(steps).size !== steps.length)
138
+ bad('a step is listed twice');
139
+ if (mode === 'overlay' && !steps.includes('flutter_create'))
140
+ bad('overlay mode needs the flutter_create step');
141
+ if (!Array.isArray(o.files) || !o.files.length || o.files.length > LIMITS.entries)
142
+ bad(`files must list 1-${LIMITS.entries} files`);
143
+ const files = o.files.map((f, n) => {
144
+ if (!isObj(f))
145
+ return bad(`files[${n}] must be an object`);
146
+ const r = f;
147
+ for (const k of Object.keys(r))
148
+ if (!['path', 'sha256', 'size', 'mode'].includes(k))
149
+ bad(`files[${n}] has unknown field "${k.slice(0, 40)}"`);
150
+ const path = checkRelPath(str(r.path, `files[${n}].path`, LIMITS.pathLength), 'manifest');
151
+ const sha256 = str(r.sha256, `files[${n}].sha256`, 64);
152
+ if (!/^[0-9a-f]{64}$/.test(sha256))
153
+ bad(`files[${n}].sha256 must be 64 lowercase hex characters`);
154
+ if (typeof r.size !== 'number' || !Number.isInteger(r.size) || r.size < 0 || r.size > LIMITS.fileBytes)
155
+ bad(`files[${n}].size is not a valid size`);
156
+ if (r.mode !== undefined && r.mode !== '644' && r.mode !== '755')
157
+ bad(`files[${n}].mode must be "644" or "755"`);
158
+ return { path, sha256, size: r.size, mode: r.mode };
159
+ });
160
+ checkCollisions(files.map((f) => f.path), 'manifest');
161
+ if (files.reduce((n, f) => n + f.size, 0) > LIMITS.expandedBytes)
162
+ throw new ToolError('archive_too_large', 'The template is larger than the safety limit.', 'Report it; nothing was written.');
163
+ if (!isObj(o.license))
164
+ bad('license must be an object');
165
+ const lic = o.license;
166
+ if (lic.file !== 'LICENSE')
167
+ bad('license.file must be "LICENSE"');
168
+ const license = { kind: str(lic.kind, 'license.kind', 60), file: 'LICENSE' };
169
+ const extras = o.extras === undefined ? undefined : Array.isArray(o.extras) && o.extras.length <= 30 ? o.extras.map((e) => str(e, 'extras', 200)) : bad('extras must be a short list');
170
+ return {
171
+ schema: SCHEMA,
172
+ id,
173
+ version,
174
+ title: str(o.title, 'title', 120),
175
+ summary: o.summary === undefined ? undefined : str(o.summary, 'summary', 500),
176
+ framework,
177
+ mode,
178
+ requires,
179
+ tested,
180
+ provides: strMap(o.provides, 'provides', /^[a-zA-Z]+$/, 60),
181
+ extras,
182
+ apiAdd,
183
+ answers,
184
+ dependencies: strMap(o.dependencies, 'dependencies', PKG_RE, 100),
185
+ devDependencies: strMap(o.devDependencies, 'devDependencies', PKG_RE, 100),
186
+ steps,
187
+ expectedConventions: strMap(o.expectedConventions, 'expectedConventions', /^[a-zA-Z]+$/, 200),
188
+ files,
189
+ license,
190
+ };
191
+ }
192
+ // ---------- Versions ----------
193
+ const parts = (v) => v.split('.').map((x) => Number(x));
194
+ export function compareVersions(a, b) {
195
+ const x = parts(a);
196
+ const y = parts(b);
197
+ for (let i = 0; i < 3; i++) {
198
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
199
+ if (d)
200
+ return d < 0 ? -1 : 1;
201
+ }
202
+ return 0;
203
+ }
204
+ /** ">=3.24 <4.0", "^1.2.0", "~2.1", "=1.0.0": every part must hold. */
205
+ export function satisfies(version, range) {
206
+ const v = version.replace(/^v/, '').split(/[-+]/)[0];
207
+ return range.trim().split(/\s+/).every((p) => {
208
+ const m = /^(\^|~|>=|<=|>|<|=)?(\d+(?:\.\d+){0,2})$/.exec(p);
209
+ if (!m)
210
+ return false;
211
+ const [, op = '=', target] = m;
212
+ const c = compareVersions(v, target);
213
+ const t = parts(target);
214
+ switch (op) {
215
+ case '>=':
216
+ return c >= 0;
217
+ case '<=':
218
+ return c <= 0;
219
+ case '>':
220
+ return c > 0;
221
+ case '<':
222
+ return c < 0;
223
+ case '^':
224
+ return c >= 0 && (t[0] > 0 ? parts(v)[0] === t[0] : parts(v)[1] === (t[1] ?? 0));
225
+ case '~':
226
+ return c >= 0 && parts(v)[0] === t[0] && parts(v)[1] === (t[1] ?? 0);
227
+ default:
228
+ return c === 0 || (t.length < 3 && parts(v).slice(0, t.length).join('.') === t.join('.'));
229
+ }
230
+ });
231
+ }
232
+ export function checkAnswer(spec, value) {
233
+ if (spec.type === 'boolean')
234
+ return typeof value === 'boolean' ? null : 'Use true or false.';
235
+ if (typeof value !== 'string')
236
+ return 'Use text.';
237
+ const max = spec.maxLength ?? (spec.type === 'string' ? 100 : 64);
238
+ if (value.length > max)
239
+ return `Use at most ${max} characters.`;
240
+ switch (spec.type) {
241
+ case 'identifier':
242
+ return /^[a-z][a-z0-9_]*$/.test(value) ? null : 'Use lowercase letters, digits and _, starting with a letter (for example shop_app).';
243
+ case 'npm-name':
244
+ return /^[a-z][a-z0-9-_]*$/.test(value) ? null : 'Use lowercase letters, digits, - and _, starting with a letter (for example shop-app).';
245
+ case 'reverse-domain':
246
+ return /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(value) ? null : 'Use reverse-domain form, for example com.acme.';
247
+ case 'choice':
248
+ return spec.choices.includes(value) ? null : `Choose one of: ${spec.choices.join(', ')}.`;
249
+ case 'string':
250
+ // Printable text only: no control characters, no placeholder braces, no backticks or $ (they mean something in code).
251
+ // eslint-disable-next-line no-control-regex
252
+ return value && !/[\u0000-\u001f\u007f{}`$\\"'<>]/.test(value) ? null : 'Use plain printable text without quotes, braces, angle brackets, backticks, $ or backslashes.';
253
+ }
254
+ return 'Unknown answer type.';
255
+ }
256
+ /** Fills in defaults and validates every answer. `given` comes from --set and the name argument. */
257
+ export function resolveAnswers(m, given) {
258
+ for (const k of Object.keys(given))
259
+ if (!m.answers.some((a) => a.key === k))
260
+ throw new ToolError('bad_answer', `This template has no question "${k}".`, `Its questions: ${m.answers.map((a) => a.key).join(', ')}`);
261
+ const out = {};
262
+ for (const a of m.answers) {
263
+ let v = given[a.key] ?? a.default;
264
+ if (a.type === 'boolean' && typeof v === 'string')
265
+ v = v === 'true' ? true : v === 'false' ? false : v;
266
+ if (v === undefined)
267
+ throw new ToolError('bad_answer', `No value for "${a.key}" (${a.prompt}).`, `Pass --set ${a.key}=<value>.`);
268
+ const problem = checkAnswer(a, v);
269
+ if (problem)
270
+ throw new ToolError('bad_answer', `The value for ${a.key} is not valid. ${problem}`, `Pass --set ${a.key}=<value>.`);
271
+ out[a.key] = v;
272
+ }
273
+ return out;
274
+ }
275
+ /** Placeholder values: every answer as {{key}}, and each reverse-domain answer as a path too ({{org.path}} = com/acme). */
276
+ export function placeholderValues(m, answers) {
277
+ const vals = new Map();
278
+ for (const a of m.answers) {
279
+ const v = answers[a.key];
280
+ vals.set(a.key, String(v));
281
+ if (a.type === 'reverse-domain')
282
+ vals.set(`${a.key}.path`, String(v).replace(/\./g, '/'));
283
+ }
284
+ return vals;
285
+ }
@@ -0,0 +1,70 @@
1
+ // Path rules for every file a starter writes. A path is checked when it is
2
+ // read from an archive or folder, and again after placeholders are filled in,
3
+ // because an answer can change a path.
4
+ import { ToolError } from "../core/types.js";
5
+ export const LIMITS = {
6
+ compressedBytes: 50 * 1024 * 1024,
7
+ expandedBytes: 200 * 1024 * 1024,
8
+ entries: 5000,
9
+ pathLength: 240,
10
+ depth: 32,
11
+ fileBytes: 50 * 1024 * 1024,
12
+ };
13
+ const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
14
+ /** Throws archive_unsafe unless `p` is a safe relative path with forward slashes. */
15
+ export function checkRelPath(p, where) {
16
+ const unsafe = (why) => {
17
+ throw new ToolError('archive_unsafe', `The ${where} has an unsafe entry "${clip(p)}": ${why}. Nothing was written.`, 'Do not use this template; report it.');
18
+ };
19
+ if (!p)
20
+ unsafe('an empty name');
21
+ if (p.length > LIMITS.pathLength)
22
+ unsafe(`a path longer than ${LIMITS.pathLength} characters`);
23
+ // eslint-disable-next-line no-control-regex
24
+ if (/[\u0000-\u001f\u007f\\:*?"<>|]/.test(p))
25
+ unsafe('a character that is not allowed in file names');
26
+ if (p.startsWith('/') || /^[a-zA-Z]:/.test(p))
27
+ unsafe('an absolute path');
28
+ const segs = p.split('/');
29
+ if (segs.length > LIMITS.depth)
30
+ unsafe(`more than ${LIMITS.depth} nested folders`);
31
+ for (const s of segs) {
32
+ if (s === '' || s === '.')
33
+ unsafe('an empty or "." path segment');
34
+ if (s === '..')
35
+ unsafe('a ".." segment');
36
+ if (RESERVED.test(s))
37
+ unsafe(`a name reserved on Windows (${s})`);
38
+ if (/[. ]$/.test(s))
39
+ unsafe('a name ending in a dot or a space');
40
+ if (s === '.git')
41
+ unsafe('a .git folder');
42
+ if (s === '.codz')
43
+ unsafe("codz's own folder");
44
+ }
45
+ return p;
46
+ }
47
+ /** Case- and Unicode-folded form: two paths with the same key collide on macOS and Windows. */
48
+ export function collisionKey(p) {
49
+ return p.normalize('NFC').toLowerCase();
50
+ }
51
+ /** Throws archive_unsafe when two file paths would land on the same file on some system, or a file is also used as a folder. */
52
+ export function checkCollisions(paths, where) {
53
+ const files = new Map();
54
+ for (const p of paths) {
55
+ const k = collisionKey(p);
56
+ const prev = files.get(k);
57
+ if (prev !== undefined)
58
+ throw new ToolError('archive_unsafe', `The ${where} has two entries that are the same file on macOS and Windows: "${clip(prev)}" and "${clip(p)}". Nothing was written.`, 'Do not use this template; report it.');
59
+ files.set(k, p);
60
+ }
61
+ for (const p of paths) {
62
+ const segs = p.split('/');
63
+ for (let n = 1; n < segs.length; n++) {
64
+ const dir = segs.slice(0, n).join('/');
65
+ if (files.has(collisionKey(dir)))
66
+ throw new ToolError('archive_unsafe', `The ${where} uses "${clip(dir)}" as both a file and a folder. Nothing was written.`, 'Do not use this template; report it.');
67
+ }
68
+ }
69
+ }
70
+ const clip = (s) => (s.length > 120 ? `${s.slice(0, 117)}...` : s).replace(/[\u0000-\u001f]/g, '?');
@@ -0,0 +1,150 @@
1
+ // Publishing starters (internal): check a template folder against the
2
+ // publishing gates, refresh its file list, pack it into a deterministic
3
+ // archive, and build and sign the catalog index. Used by scripts/starters.ts
4
+ // and by the tests' fake registry. Nothing here runs on a user's `codz new`.
5
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { ToolError } from "../core/types.js";
8
+ import { planNew } from "./engine.js";
9
+ import { resolveAnswers } from "./manifest.js";
10
+ import { sha256, signatureFile } from "./sign.js";
11
+ import { fromFolder } from "./source.js";
12
+ import { parseStrictJson } from "./strictJson.js";
13
+ import { writeTarGz } from "./tar.js";
14
+ /** Rewrites manifest.json's file list (paths, hashes, sizes, executable bits) from files/. */
15
+ export function refreshFileList(dir) {
16
+ const files = [];
17
+ const walk = (rel) => {
18
+ for (const name of readdirSync(join(dir, 'files', rel)).sort()) {
19
+ const p = rel ? `${rel}/${name}` : name;
20
+ const st = lstatSync(join(dir, 'files', p));
21
+ if (st.isDirectory())
22
+ walk(p);
23
+ else if (st.isFile()) {
24
+ const data = readFileSync(join(dir, 'files', p));
25
+ files.push({ path: p, sha256: sha256(data), size: data.length, ...(st.mode & 0o111 ? { mode: '755' } : {}) });
26
+ }
27
+ else
28
+ throw new ToolError('archive_unsafe', `files/${p} is a link or special file; templates hold regular files only.`);
29
+ }
30
+ };
31
+ walk('');
32
+ const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'));
33
+ m.files = files;
34
+ writeFileSync(join(dir, 'manifest.json'), JSON.stringify(m, null, 2) + '\n');
35
+ return files.length;
36
+ }
37
+ const SECRET_PATTERNS = [
38
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'a private key'],
39
+ [/\bAKIA[0-9A-Z]{16}\b/, 'an AWS access key'],
40
+ [/\bgh[pousr]_[A-Za-z0-9]{36,}\b/, 'a GitHub token'],
41
+ [/\bsk_(live|test)_[A-Za-z0-9]{16,}\b/, 'a Stripe key'],
42
+ [/\bAIza[0-9A-Za-z_-]{35}\b/, 'a Google API key'],
43
+ ];
44
+ /** The static and determinism gates: everything that can be checked without an SDK. */
45
+ export function checkTemplate(dir, codzVersion) {
46
+ const errors = [];
47
+ const warnings = [];
48
+ const t = fromFolder(dir); // manifest schema, paths, hashes, links
49
+ const m = t.manifest;
50
+ const keys = new Set(m.answers.flatMap((a) => (a.type === 'reverse-domain' ? [a.key, `${a.key}.path`] : [a.key])));
51
+ for (const [path, data] of t.files) {
52
+ for (const k of [...path.matchAll(/\{\{([^}]+)\}\}/g)].map((x) => x[1]))
53
+ if (!keys.has(k))
54
+ errors.push(`${path}: the path uses {{${k}}}, which no answer fills in`);
55
+ const text = data.includes(0) ? '' : data.toString('utf8');
56
+ for (const [re, what] of SECRET_PATTERNS)
57
+ if (re.test(text))
58
+ errors.push(`${path}: looks like it contains ${what}`);
59
+ for (const k of new Set([...text.matchAll(/\{\{([a-zA-Z][a-zA-Z0-9]*(?:\.path)?)\}\}/g)].map((x) => x[1])))
60
+ if (!keys.has(k))
61
+ warnings.push(`${path}: {{${k}}} is not an answer, so it is left as written`);
62
+ }
63
+ if (!t.license.trim())
64
+ errors.push('LICENSE is empty');
65
+ if (!m.extras?.length)
66
+ warnings.push('No "extras": the catalog page will not say what this starter adds beyond codz create');
67
+ if (!m.tested)
68
+ warnings.push('No "tested" block: fill it from the CI matrix results');
69
+ if (!Object.keys(m.expectedConventions ?? {}).length)
70
+ errors.push('No expectedConventions: the init contract test has nothing to check');
71
+ // Determinism: the same answers give the same files.
72
+ const answers = resolveAnswers(m, {});
73
+ const plan = () => planNew(t, answers, '/tmp/codz-check', codzVersion, null, { steps: true });
74
+ const a = plan();
75
+ const b = plan();
76
+ const digest = (p) => sha256(Buffer.concat(p.files.flatMap((f) => [Buffer.from(`${f.path}\0${f.mode}\0`), f.data])));
77
+ if (digest(a) !== digest(b))
78
+ errors.push('Two builds with the same answers differ');
79
+ return { id: m.id, version: m.version, errors, warnings };
80
+ }
81
+ /** Packs a checked template folder: <out>/v1/templates/<id>/<version>/{archive.tar.gz, manifest.json}. */
82
+ export function packTemplate(dir, out) {
83
+ const t = fromFolder(dir);
84
+ const m = t.manifest;
85
+ const entries = new Map([
86
+ ['manifest.json', t.manifestBytes],
87
+ ['LICENSE', Buffer.from(t.license)],
88
+ ]);
89
+ const exec = new Set();
90
+ for (const f of m.files) {
91
+ entries.set(`files/${f.path}`, t.files.get(f.path));
92
+ if (f.mode === '755')
93
+ exec.add(`files/${f.path}`);
94
+ }
95
+ const gz = writeTarGz(entries, exec);
96
+ const dest = join(out, 'v1', 'templates', m.id, m.version);
97
+ mkdirSync(dest, { recursive: true });
98
+ const archive = join(dest, 'archive.tar.gz');
99
+ if (existsSync(archive) && sha256(readFileSync(archive)) !== sha256(gz))
100
+ throw new ToolError('version_exists', `${m.id} ${m.version} is already published with different contents.`, 'A published version never changes: raise the version number.');
101
+ writeFileSync(archive, gz);
102
+ writeFileSync(join(dest, 'manifest.json'), t.manifestBytes);
103
+ return { id: m.id, version: m.version, archiveSha256: sha256(gz), manifestSha256: sha256(t.manifestBytes), archive };
104
+ }
105
+ /** Builds v1/index.json from the packed versions and catalog.json, with seq + 1, and signs it. */
106
+ export function buildIndex(root, privatePems, opts = {}) {
107
+ const catalog = parseStrictJson(readFileSync(join(root, 'catalog.json'), 'utf8'), 'catalog.json');
108
+ const prevPath = join(root, 'v1', 'index.json');
109
+ const prevSeq = existsSync(prevPath) ? JSON.parse(readFileSync(prevPath, 'utf8')).seq : 0;
110
+ const now = opts.now ?? new Date();
111
+ const templates = [];
112
+ for (const c of catalog.templates) {
113
+ const base = join(root, 'v1', 'templates', c.id);
114
+ if (!existsSync(base))
115
+ throw new ToolError('template_not_found', `catalog.json lists ${c.id}, but nothing is packed for it.`);
116
+ const versions = readdirSync(base).filter((v) => statSync(join(base, v)).isDirectory()).sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
117
+ let latestManifest = null;
118
+ const vs = versions.map((v) => {
119
+ const archive = readFileSync(join(base, v, 'archive.tar.gz'));
120
+ const manifestBytes = readFileSync(join(base, v, 'manifest.json'));
121
+ const m = JSON.parse(manifestBytes.toString('utf8'));
122
+ if (!c.yanked?.includes(v))
123
+ latestManifest = m;
124
+ return { version: v, archive: { sha256: sha256(archive), size: archive.length }, manifest: { sha256: sha256(manifestBytes) }, tested: m.tested, ...(c.yanked?.includes(v) ? { yanked: true } : {}), ...(c.needsUpdate?.includes(v) ? { needsUpdate: true } : {}) };
125
+ });
126
+ const lm = (latestManifest ?? JSON.parse(readFileSync(join(base, versions[versions.length - 1], 'manifest.json'), 'utf8')));
127
+ templates.push({ id: c.id, title: c.title ?? lm.title, summary: c.summary ?? lm.summary, framework: lm.framework, tier: c.tier, price: c.price, provides: lm.provides, apiAdd: lm.apiAdd.supported, extras: lm.extras, latest: lm.version, versions: vs });
128
+ }
129
+ const index = { schema: 1, seq: prevSeq + 1, issued: now.toISOString(), expires: new Date(now.getTime() + (opts.days ?? 7) * 86_400_000).toISOString(), templates, revoked: catalog.revoked ?? [], ...(opts.timestampKeys?.length ? { timestampKeys: opts.timestampKeys } : {}) };
130
+ const bytes = Buffer.from(JSON.stringify(index, null, 2) + '\n');
131
+ mkdirSync(join(root, 'v1'), { recursive: true });
132
+ writeFileSync(prevPath, bytes);
133
+ writeFileSync(`${prevPath}.sig`, signatureFile(bytes, privatePems));
134
+ return index;
135
+ }
136
+ /**
137
+ * Signs v1/timestamp.json for the current v1/index.json with an online key: "this index
138
+ * is current until <expires>". The store does this every few hours; this is the same
139
+ * thing for a cron job or a static host.
140
+ */
141
+ export function signTimestamp(root, privatePem, opts = {}) {
142
+ const bytes = readFileSync(join(root, 'v1', 'index.json'));
143
+ const index = JSON.parse(bytes.toString('utf8'));
144
+ const now = opts.now ?? new Date();
145
+ const ts = { schema: 1, index_sha256: sha256(bytes), seq: index.seq, issued: now.toISOString(), expires: new Date(now.getTime() + (opts.hours ?? 48) * 3_600_000).toISOString() };
146
+ const out = Buffer.from(JSON.stringify(ts, null, 2) + '\n');
147
+ writeFileSync(join(root, 'v1', 'timestamp.json'), out);
148
+ writeFileSync(join(root, 'v1', 'timestamp.json.sig'), signatureFile(out, [privatePem]));
149
+ return { seq: index.seq, expires: ts.expires };
150
+ }