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.
Files changed (117) hide show
  1. package/BETA.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +523 -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 +358 -0
  77. package/dist/skills/doctor.js +304 -0
  78. package/dist/skills/doctorFix.js +214 -0
  79. package/dist/skills/explain.js +61 -0
  80. package/dist/skills/feature.js +418 -0
  81. package/dist/skills/featureEdit.js +225 -0
  82. package/dist/skills/featureLinks.js +179 -0
  83. package/dist/skills/init.js +66 -0
  84. package/dist/skills/iosUi.js +468 -0
  85. package/dist/skills/locale.js +335 -0
  86. package/dist/skills/mcp.js +175 -0
  87. package/dist/skills/mock.js +482 -0
  88. package/dist/skills/module.js +23 -0
  89. package/dist/skills/plans.js +66 -0
  90. package/dist/skills/prefill.js +84 -0
  91. package/dist/skills/reactNativeUi.js +388 -0
  92. package/dist/skills/reactUi.js +352 -0
  93. package/dist/skills/report.js +75 -0
  94. package/dist/skills/route.js +230 -0
  95. package/dist/skills/screen.js +597 -0
  96. package/dist/skills/screenForm.js +410 -0
  97. package/dist/skills/setup.js +28 -0
  98. package/dist/skills/setupDoctor.js +226 -0
  99. package/dist/skills/templatesCmd.js +32 -0
  100. package/dist/skills/testgen.js +331 -0
  101. package/dist/skills/theme.js +388 -0
  102. package/dist/starters/auth.js +144 -0
  103. package/dist/starters/commands.js +283 -0
  104. package/dist/starters/engine.js +315 -0
  105. package/dist/starters/home.js +42 -0
  106. package/dist/starters/http.js +93 -0
  107. package/dist/starters/manifest.js +285 -0
  108. package/dist/starters/paths.js +70 -0
  109. package/dist/starters/publish.js +150 -0
  110. package/dist/starters/registry.js +258 -0
  111. package/dist/starters/sign.js +72 -0
  112. package/dist/starters/source.js +98 -0
  113. package/dist/starters/strictJson.js +148 -0
  114. package/dist/starters/tar.js +180 -0
  115. package/docs/COMMANDS.md +527 -0
  116. package/examples/openapi.yaml +319 -0
  117. package/package.json +57 -4
@@ -0,0 +1,258 @@
1
+ // The catalog client: a signed index on a static host, public manifests,
2
+ // free archives as public files and paid archives through a short-lived
3
+ // download URL. Everything it returns has been checked against the signed
4
+ // index; the engine only ever receives a VerifiedTemplate.
5
+ //
6
+ // The index is refused when its signature fails, when its sequence number is
7
+ // lower than one this machine has seen (or equal with different contents), and
8
+ // when it has expired, so a mirror cannot replay or freeze an old catalog to
9
+ // hide a revocation. A revoked version is refused even from the cache.
10
+ import { ToolError } from "../core/types.js";
11
+ import { auth } from "./auth.js";
12
+ import { readHomeFile, removeHome, writeHomeFile } from "./home.js";
13
+ import { apiError, checkUrl, request } from "./http.js";
14
+ import { compareVersions, ID_RE, parseManifest, VERSION_RE } from "./manifest.js";
15
+ import { LIMITS } from "./paths.js";
16
+ import { keyId, OFFICIAL_KEYS, sha256, verifySignature } from "./sign.js";
17
+ import { fromArchive } from "./source.js";
18
+ import { parseStrictJson } from "./strictJson.js";
19
+ /** The official catalog. Empty until it is live; CODZ_REGISTRY points at another one (staging, tests). */
20
+ export const OFFICIAL_REGISTRY = { base: '', api: '' };
21
+ export function registryConfig() {
22
+ const env = process.env.CODZ_REGISTRY;
23
+ if (env) {
24
+ const keys = {};
25
+ for (const k of (process.env.CODZ_REGISTRY_KEYS ?? '').split(',').map((x) => x.trim()).filter(Boolean))
26
+ keys[keyId(k)] = k;
27
+ return { base: checkUrl(env, 'CODZ_REGISTRY'), api: checkUrl(process.env.CODZ_REGISTRY_API || env, 'CODZ_REGISTRY_API'), keys, official: false };
28
+ }
29
+ if (!OFFICIAL_REGISTRY.base)
30
+ throw new ToolError('catalog_unavailable', 'The starter catalog is not online yet.', 'Use a template folder: codz new <name> --template ./my-starter');
31
+ return { base: checkUrl(OFFICIAL_REGISTRY.base, 'registry'), api: checkUrl(OFFICIAL_REGISTRY.api || OFFICIAL_REGISTRY.base, 'registry API'), keys: OFFICIAL_KEYS, official: true };
32
+ }
33
+ const join = (base, path) => new URL(path.replace(/^\//, ''), base.href.endsWith('/') ? base : new URL(`${base.href}/`));
34
+ const badIndex = (why) => new ToolError('bad_template', `The catalog index is not valid: ${why}.`, 'Try again later; report it if it persists.');
35
+ export function parseIndex(text) {
36
+ const raw = parseStrictJson(text, 'the catalog index', { maxBytes: 8 * 1024 * 1024 });
37
+ if (!raw || typeof raw !== 'object' || raw.schema !== 1)
38
+ throw badIndex('unknown schema');
39
+ if (!Number.isInteger(raw.seq) || raw.seq < 0)
40
+ throw badIndex('bad sequence number');
41
+ if (Number.isNaN(Date.parse(raw.issued)) || Number.isNaN(Date.parse(raw.expires)))
42
+ throw badIndex('bad dates');
43
+ if (!Array.isArray(raw.templates) || !Array.isArray(raw.revoked))
44
+ throw badIndex('missing lists');
45
+ if (raw.timestampKeys !== undefined && (!Array.isArray(raw.timestampKeys) || raw.timestampKeys.length > 8 || !raw.timestampKeys.every((k) => typeof k === 'string' && /^[A-Za-z0-9+/]{43}=$/.test(k))))
46
+ throw badIndex('bad timestamp keys');
47
+ for (const t of raw.templates) {
48
+ if (!ID_RE.test(t?.id ?? '') || typeof t.title !== 'string' || !Array.isArray(t.versions) || !VERSION_RE.test(t.latest ?? ''))
49
+ throw badIndex(`a bad entry ${String(t?.id).slice(0, 40)}`);
50
+ if (t.tier !== 'free' && t.tier !== 'paid')
51
+ throw badIndex(`${t.id} has no price tier`);
52
+ for (const v of t.versions)
53
+ if (!VERSION_RE.test(v?.version ?? '') || !/^[0-9a-f]{64}$/.test(v.archive?.sha256 ?? '') || !/^[0-9a-f]{64}$/.test(v.manifest?.sha256 ?? ''))
54
+ throw badIndex(`${t.id} has a bad version entry`);
55
+ }
56
+ return raw;
57
+ }
58
+ export function parseTimestamp(text) {
59
+ const t = parseStrictJson(text, 'the catalog timestamp', { maxBytes: 16 * 1024 });
60
+ if (!t || t.schema !== 1 || !/^[0-9a-f]{64}$/.test(t.index_sha256 ?? '') || !Number.isInteger(t.seq) || Number.isNaN(Date.parse(t.issued)) || Number.isNaN(Date.parse(t.expires)))
61
+ throw badIndex('the timestamp file is not valid');
62
+ return t;
63
+ }
64
+ /** Checks timestamp.json against the index it must pin. Returns it, or throws index_expired / index_rollback. */
65
+ function checkTimestamp(index, digest, tsBytes, tsSig, state) {
66
+ const keys = {};
67
+ for (const k of index.timestampKeys ?? [])
68
+ keys[keyId(k)] = k;
69
+ verifySignature(tsBytes, tsSig, keys, 'the catalog timestamp');
70
+ const ts = parseTimestamp(tsBytes.toString('utf8'));
71
+ if (ts.index_sha256 !== digest || ts.seq !== index.seq)
72
+ throw new ToolError('index_expired', 'The catalog\'s freshness note is for a different catalog, so it may be out of date.', 'Retry in a minute; report it if it persists.');
73
+ if (state?.tsIssued && Date.parse(ts.issued) < Date.parse(state.tsIssued) && state.sha256 === digest)
74
+ throw new ToolError('index_rollback', 'The catalog\'s freshness note is older than one this machine has already seen.', 'Retry later; report it if it persists. Nothing was downloaded.');
75
+ if (Date.parse(ts.expires) < Date.now())
76
+ throw new ToolError('index_expired', `The catalog has not been confirmed as current since ${ts.issued.slice(0, 16).replace('T', ' ')} UTC, so it may be hiding withdrawn versions.`, 'Retry later; report it if it persists.');
77
+ return ts;
78
+ }
79
+ function stored(cfg) {
80
+ const bytes = readHomeFile('starters', 'index.json');
81
+ const sig = readHomeFile('starters', 'index.json.sig');
82
+ const st = readHomeFile('starters', 'state.json');
83
+ if (!bytes || !sig || !st)
84
+ return null;
85
+ try {
86
+ const state = JSON.parse(st.toString('utf8'));
87
+ if (state.registry !== cfg.base.href)
88
+ return null;
89
+ return { bytes, sig, state };
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ export async function loadIndex(opts = {}) {
96
+ const cfg = registryConfig();
97
+ const warnings = cfg.official ? [] : [`Using the catalog at ${cfg.base.host} from CODZ_REGISTRY, not the official one.`];
98
+ const have = stored(cfg);
99
+ if (opts.cached) {
100
+ if (!have)
101
+ throw new ToolError('offline_not_cached', 'There is no downloaded catalog on this machine.', 'Run once online, or use a template folder.');
102
+ verifySignature(have.bytes, have.sig, cfg.keys, 'the catalog index');
103
+ const index = parseIndex(have.bytes.toString('utf8'));
104
+ let current = Date.parse(index.expires);
105
+ if (index.timestampKeys?.length) {
106
+ const tb = readHomeFile('starters', 'timestamp.json');
107
+ const tsig = readHomeFile('starters', 'timestamp.json.sig');
108
+ let ts = null;
109
+ try {
110
+ if (tb && tsig) {
111
+ const keys = {};
112
+ for (const k of index.timestampKeys)
113
+ keys[keyId(k)] = k;
114
+ verifySignature(tb, tsig, keys, 'the catalog timestamp');
115
+ ts = parseTimestamp(tb.toString('utf8'));
116
+ }
117
+ }
118
+ catch {
119
+ ts = null;
120
+ }
121
+ current = Math.min(current, ts && ts.index_sha256 === sha256(have.bytes) ? Date.parse(ts.expires) : 0);
122
+ }
123
+ if (current < Date.now())
124
+ warnings.push('The downloaded catalog is no longer confirmed as current; withdrawals announced since then are not known on this machine.');
125
+ return { index, warnings, fromCache: true };
126
+ }
127
+ const r = await request(join(cfg.base, 'v1/index.json'), { etag: !opts.refresh ? have?.state.etag : undefined, maxBytes: 8 * 1024 * 1024 });
128
+ let bytes;
129
+ let sig;
130
+ if (r.status === 304 && have) {
131
+ bytes = have.bytes;
132
+ sig = have.sig;
133
+ }
134
+ else if (r.status === 200) {
135
+ bytes = r.body;
136
+ const s = await request(join(cfg.base, 'v1/index.json.sig'), { maxBytes: 16 * 1024 });
137
+ if (s.status !== 200)
138
+ throw apiError(s, { hadToken: false, what: 'the catalog signature' });
139
+ sig = s.body;
140
+ }
141
+ else
142
+ throw apiError(r, { hadToken: false, what: 'the catalog index' });
143
+ verifySignature(bytes, sig, cfg.keys, 'the catalog index');
144
+ const index = parseIndex(bytes.toString('utf8'));
145
+ const digest = sha256(bytes);
146
+ if (have) {
147
+ if (index.seq < have.state.seq || (index.seq === have.state.seq && digest !== have.state.sha256)) {
148
+ throw new ToolError('index_rollback', `The catalog (number ${index.seq}) is older than one this machine has already seen (number ${have.state.seq}).`, 'Retry later; report it if it persists. Nothing was downloaded.');
149
+ }
150
+ }
151
+ if (Date.parse(index.expires) < Date.now())
152
+ throw new ToolError('index_expired', `The catalog expired on ${index.expires.slice(0, 10)}, so it may be hiding withdrawn versions.`, 'Retry later; report it if it persists.');
153
+ // Two-key catalogs: the offline-signed index names online keys, and one of them confirms every few hours that this index is current.
154
+ let ts = null;
155
+ if (index.timestampKeys?.length) {
156
+ const t = await request(join(cfg.base, 'v1/timestamp.json'), { maxBytes: 16 * 1024 });
157
+ const ts2 = await request(join(cfg.base, 'v1/timestamp.json.sig'), { maxBytes: 16 * 1024 });
158
+ if (t.status !== 200 || ts2.status !== 200)
159
+ throw new ToolError('index_expired', 'The catalog\'s freshness note is missing, so it may be out of date.', 'Retry later; report it if it persists.');
160
+ ts = { bytes: t.body, sig: ts2.body, parsed: checkTimestamp(index, digest, t.body, ts2.body, have?.state) };
161
+ }
162
+ writeHomeFile(bytes, {}, 'starters', 'index.json');
163
+ writeHomeFile(sig, {}, 'starters', 'index.json.sig');
164
+ if (ts) {
165
+ writeHomeFile(ts.bytes, {}, 'starters', 'timestamp.json');
166
+ writeHomeFile(ts.sig, {}, 'starters', 'timestamp.json.sig');
167
+ }
168
+ writeHomeFile(JSON.stringify({ seq: index.seq, sha256: digest, etag: r.headers.get('etag') ?? undefined, registry: cfg.base.href, tsIssued: ts?.parsed.issued ?? (have?.state.sha256 === digest ? have?.state.tsIssued : undefined) }), {}, 'starters', 'state.json');
169
+ return { index, warnings, fromCache: false };
170
+ }
171
+ // ---------- Resolving and downloading ----------
172
+ export function parseRef(ref) {
173
+ const [id, version] = ref.split('@');
174
+ if (!ID_RE.test(id ?? '') || (version !== undefined && !VERSION_RE.test(version)))
175
+ throw new ToolError('bad_args', `"${ref.slice(0, 80)}" is not a starter id (use id or id@x.y.z), or a folder.`, 'List starters with: codz starters list');
176
+ return { id, version };
177
+ }
178
+ export function resolveRef(index, ref) {
179
+ const { id, version } = parseRef(ref);
180
+ const t = index.templates.find((x) => x.id === id);
181
+ if (!t)
182
+ throw new ToolError('template_not_found', `No starter called ${id}.`, 'Check the id with codz starters list.');
183
+ const want = version ?? t.latest;
184
+ const v = t.versions.find((x) => x.version === want);
185
+ if (!v)
186
+ throw new ToolError('version_not_found', `No version ${want} of ${id}.`, `Versions: ${t.versions.map((x) => x.version).join(', ')}`);
187
+ const revoked = index.revoked.find((x) => x.id === id && x.version === want);
188
+ if (revoked)
189
+ throw new ToolError('template_revoked', `${id} ${want} was withdrawn${revoked.reason ? `: ${revoked.reason}` : ''}.`, revoked.use ? `Use ${id}@${revoked.use}.` : `Use a newer version (latest: ${t.latest}).`);
190
+ const warnings = [];
191
+ if (v.yanked)
192
+ warnings.push(`${id} ${want} was replaced by a newer version (latest: ${t.latest}).`);
193
+ if (v.needsUpdate)
194
+ warnings.push(`${id} ${want} is marked "needs update": it did not pass the latest SDK tests.`);
195
+ return { template: t, version: v, warnings };
196
+ }
197
+ export function latestUsable(t, index) {
198
+ const ok = t.versions.filter((v) => !v.yanked && !index.revoked.some((r) => r.id === t.id && r.version === v.version)).map((v) => v.version);
199
+ return ok.sort(compareVersions).pop() ?? t.latest;
200
+ }
201
+ /** The archive for one index entry, from the cache or the catalog, checked against the signed index. */
202
+ export async function fetchTemplate(index, t, v, opts = {}) {
203
+ const cfg = registryConfig();
204
+ const cachePath = ['starters', 'cache', t.id, v.version, 'archive.tar.gz'];
205
+ let gz = readHomeFile(...cachePath);
206
+ let origin = 'cache';
207
+ if (gz && sha256(gz) !== v.archive.sha256) {
208
+ removeHome('starters', 'cache', t.id, v.version);
209
+ gz = null;
210
+ if (opts.cached)
211
+ throw new ToolError('checksum_mismatch', `The downloaded copy of ${t.id} ${v.version} does not match the catalog, so it was removed.`, 'Run again without --cached to download it again.');
212
+ }
213
+ if (!gz) {
214
+ if (opts.cached)
215
+ throw new ToolError('offline_not_cached', `${t.id} ${v.version} is not downloaded on this machine.`, 'Run once online, or use a template folder.');
216
+ let url;
217
+ let token;
218
+ if (t.tier === 'paid') {
219
+ // Downloading a paid starter uses the account's entitlement: only a person at a terminal starts that.
220
+ if (process.env.CODZ_MCP === '1')
221
+ throw new ToolError('needs_approval', `${t.id} is a paid starter and is not downloaded on this machine yet.`, `Ask the user to run codz new <name> --template ${t.id}@${v.version} in a terminal (or --dry-run to only download it); then it can be planned here.`);
222
+ token = await auth.accessToken();
223
+ if (!token)
224
+ throw new ToolError('not_logged_in', `${t.id} needs an account.`, 'Run codz login (or set CODZ_TOKEN in CI).');
225
+ const r = await request(join(cfg.api, 'v1/download-url'), { method: 'POST', json: { id: t.id, version: v.version }, token, maxBytes: 64 * 1024 });
226
+ if (r.status !== 200)
227
+ throw apiError(r, { hadToken: true, what: `${t.id} ${v.version}` });
228
+ const u = parseStrictJson(r.body.toString('utf8'), 'the download answer').url;
229
+ url = checkUrl(String(u ?? ''), 'the download URL');
230
+ }
231
+ else
232
+ url = join(cfg.base, `v1/templates/${t.id}/${v.version}/archive.tar.gz`);
233
+ const r = await request(url, { maxBytes: LIMITS.compressedBytes, timeoutMs: 120_000 });
234
+ if (r.status !== 200)
235
+ throw apiError(r, { hadToken: !!token, what: `${t.id} ${v.version}` });
236
+ gz = r.body;
237
+ if (sha256(gz) !== v.archive.sha256)
238
+ throw new ToolError('checksum_mismatch', `The download of ${t.id} ${v.version} does not match the signed catalog.`, 'Retry; if it persists, do not use the template and report it.');
239
+ writeHomeFile(gz, {}, ...cachePath);
240
+ origin = 'catalog';
241
+ }
242
+ const verified = fromArchive(gz, origin);
243
+ if (verified.manifest.id !== t.id || verified.manifest.version !== v.version)
244
+ throw new ToolError('bad_template', `The archive says it is ${verified.manifest.id} ${verified.manifest.version}, not ${t.id} ${v.version}.`, 'Do not use it; report it.');
245
+ if (sha256(verified.manifestBytes) !== v.manifest.sha256)
246
+ throw new ToolError('checksum_mismatch', `The manifest of ${t.id} ${v.version} does not match the signed catalog.`, 'Do not use it; report it.');
247
+ return verified;
248
+ }
249
+ /** A public manifest, checked against the signed index (for starters show). */
250
+ export async function fetchManifest(t, v) {
251
+ const cfg = registryConfig();
252
+ const r = await request(join(cfg.base, `v1/templates/${t.id}/${v.version}/manifest.json`), { maxBytes: 4 * 1024 * 1024 });
253
+ if (r.status !== 200)
254
+ throw apiError(r, { hadToken: false, what: `${t.id} ${v.version}` });
255
+ if (sha256(r.body) !== v.manifest.sha256)
256
+ throw new ToolError('checksum_mismatch', `The manifest of ${t.id} ${v.version} does not match the signed catalog.`, 'Retry later; report it if it persists.');
257
+ return parseManifest(r.body.toString('utf8'));
258
+ }
@@ -0,0 +1,72 @@
1
+ // Ed25519 signatures with Node's built-in crypto. A signature file sits next
2
+ // to the signed file (index.json → index.json.sig) and signs its exact bytes,
3
+ // never a re-serialised copy. It may hold several signatures, so a new key can
4
+ // be added before an old one is retired.
5
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
6
+ import { ToolError } from "../core/types.js";
7
+ import { parseStrictJson } from "./strictJson.js";
8
+ /**
9
+ * Public keys this codz release trusts for the official catalog, by key id.
10
+ * Empty until the catalog is live; a registry set with CODZ_REGISTRY brings
11
+ * its own keys in CODZ_REGISTRY_KEYS.
12
+ */
13
+ export const OFFICIAL_KEYS = {};
14
+ const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
15
+ export function publicKeyFromRaw(rawBase64) {
16
+ const raw = Buffer.from(rawBase64, 'base64');
17
+ if (raw.length !== 32)
18
+ throw new ToolError('signature_invalid', 'A trusted key is not a 32-byte Ed25519 public key.', 'Check CODZ_REGISTRY_KEYS.');
19
+ return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
20
+ }
21
+ export function keyId(rawBase64) {
22
+ return createHash('sha256').update(Buffer.from(rawBase64, 'base64')).digest('hex').slice(0, 16);
23
+ }
24
+ export function sha256(data) {
25
+ return createHash('sha256').update(data).digest('hex');
26
+ }
27
+ /** Throws signature_invalid unless at least one signature by a trusted key verifies over `data`. */
28
+ export function verifySignature(data, sigFile, trusted, what) {
29
+ const fail = (why) => {
30
+ throw new ToolError('signature_invalid', `The signature of ${what} could not be verified (${why}).`, 'Update codz; if it persists, do not use the catalog and report it.');
31
+ };
32
+ if (!Object.keys(trusted).length)
33
+ fail('this codz trusts no catalog keys');
34
+ let parsed;
35
+ try {
36
+ parsed = parseStrictJson(sigFile.toString('utf8'), `${what} signature`, { maxBytes: 16 * 1024 });
37
+ }
38
+ catch {
39
+ return fail('the signature file is not valid');
40
+ }
41
+ const sigs = parsed?.signatures;
42
+ if (!Array.isArray(sigs) || !sigs.length || sigs.length > 8)
43
+ fail('no signatures');
44
+ for (const s of sigs) {
45
+ if (typeof s?.keyid !== 'string' || typeof s?.sig !== 'string')
46
+ continue;
47
+ const raw = trusted[s.keyid];
48
+ if (!raw)
49
+ continue;
50
+ const sig = Buffer.from(s.sig, 'base64');
51
+ if (sig.length !== 64)
52
+ continue;
53
+ if (verify(null, data, publicKeyFromRaw(raw), sig))
54
+ return s.keyid;
55
+ }
56
+ return fail('no signature from a trusted key matches');
57
+ }
58
+ // ---------- Publishing side (scripts/starters.ts and tests) ----------
59
+ export function generateKeys() {
60
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
61
+ const der = publicKey.export({ format: 'der', type: 'spki' });
62
+ const publicRaw = der.subarray(SPKI_PREFIX.length).toString('base64');
63
+ return { privatePem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), publicRaw, keyid: keyId(publicRaw) };
64
+ }
65
+ export function signBytes(data, privatePem) {
66
+ const key = createPrivateKey(privatePem);
67
+ const der = createPublicKey(key).export({ format: 'der', type: 'spki' });
68
+ return { keyid: keyId(der.subarray(SPKI_PREFIX.length).toString('base64')), sig: sign(null, data, key).toString('base64') };
69
+ }
70
+ export function signatureFile(data, privatePems) {
71
+ return Buffer.from(JSON.stringify({ signatures: privatePems.map((p) => signBytes(data, p)) }, null, 2) + '\n');
72
+ }
@@ -0,0 +1,98 @@
1
+ // A template source: the manifest, the license and the project files, read
2
+ // from a folder or an archive and verified against the manifest's hashes.
3
+ // The engine only ever sees a VerifiedTemplate, so a template from the
4
+ // catalog, the cache or the user's own disk goes through the same checks.
5
+ import { lstatSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { ToolError } from "../core/types.js";
8
+ import { parseManifest } from "./manifest.js";
9
+ import { checkCollisions, checkRelPath, LIMITS } from "./paths.js";
10
+ import { sha256 } from "./sign.js";
11
+ import { readTarGz } from "./tar.js";
12
+ /** Checks every listed file is present with the right size and hash, and nothing else is there. */
13
+ export function verifyFiles(m, files) {
14
+ const listed = new Set(m.files.map((f) => f.path));
15
+ const extra = [...files.keys()].filter((p) => !listed.has(p));
16
+ if (extra.length)
17
+ throw new ToolError('checksum_mismatch', `The template has files its manifest does not list: ${extra.slice(0, 5).join(', ')}${extra.length > 5 ? ', ...' : ''}.`, 'Clear the cache with codz starters cache --clear and retry; report it if it persists.');
18
+ for (const f of m.files) {
19
+ const data = files.get(f.path);
20
+ if (!data)
21
+ throw new ToolError('checksum_mismatch', `The template is missing ${f.path}.`, 'Clear the cache with codz starters cache --clear and retry; report it if it persists.');
22
+ if (data.length !== f.size || sha256(data) !== f.sha256)
23
+ throw new ToolError('checksum_mismatch', `${f.path} does not match its hash in the manifest.`, 'Clear the cache with codz starters cache --clear and retry; report it if it persists.');
24
+ }
25
+ }
26
+ export function fromArchive(gz, origin) {
27
+ const { files: all } = readTarGz(gz);
28
+ const manifestBytes = all.get('manifest.json');
29
+ const license = all.get('LICENSE');
30
+ if (!manifestBytes)
31
+ throw new ToolError('bad_template', 'The archive has no manifest.json.', 'Report it; the template is not usable as published.');
32
+ if (!license)
33
+ throw new ToolError('bad_template', 'The archive has no LICENSE.', 'Report it; the template is not usable as published.');
34
+ const files = new Map();
35
+ for (const [p, data] of all) {
36
+ if (p === 'manifest.json' || p === 'LICENSE')
37
+ continue;
38
+ if (!p.startsWith('files/'))
39
+ throw new ToolError('archive_unsafe', `The archive has an entry outside files/: "${p.slice(0, 120)}". Nothing was written.`, 'Do not use this template; report it.');
40
+ files.set(p.slice('files/'.length), data);
41
+ }
42
+ const manifest = parseManifest(manifestBytes.toString('utf8'));
43
+ verifyFiles(manifest, files);
44
+ return { manifest, manifestBytes, license: license.toString('utf8'), files, origin, archiveSha256: sha256(gz) };
45
+ }
46
+ /** A template folder on disk: manifest.json, LICENSE and files/. Links and special files are refused. */
47
+ export function fromFolder(dir) {
48
+ const read = (name) => {
49
+ try {
50
+ const st = lstatSync(join(dir, name));
51
+ if (!st.isFile())
52
+ throw new ToolError('archive_unsafe', `${name} in the template folder is not a regular file.`, 'Templates hold regular files only.');
53
+ return readFileSync(join(dir, name));
54
+ }
55
+ catch (e) {
56
+ if (e instanceof ToolError)
57
+ throw e;
58
+ throw new ToolError('template_not_found', `${join(dir, name)} was not found.`, 'A template folder has manifest.json, LICENSE and files/.');
59
+ }
60
+ };
61
+ const manifestBytes = read('manifest.json');
62
+ const license = read('LICENSE');
63
+ const files = new Map();
64
+ let total = 0;
65
+ const walkDir = (rel) => {
66
+ for (const name of readdirSync(join(dir, 'files', rel)).sort()) {
67
+ const p = rel ? `${rel}/${name}` : name;
68
+ checkRelPath(p, 'template folder');
69
+ const st = lstatSync(join(dir, 'files', p));
70
+ if (st.isSymbolicLink())
71
+ throw new ToolError('archive_unsafe', `The template folder has a link: files/${p}. Nothing was written.`, 'Templates hold regular files only.');
72
+ if (st.isDirectory())
73
+ walkDir(p);
74
+ else if (st.isFile()) {
75
+ if (files.size >= LIMITS.entries)
76
+ throw new ToolError('archive_too_large', `The template folder has more than ${LIMITS.entries} files.`);
77
+ total += st.size;
78
+ if (total > LIMITS.expandedBytes)
79
+ throw new ToolError('archive_too_large', 'The template folder is larger than the safety limit.');
80
+ files.set(p, readFileSync(join(dir, 'files', p)));
81
+ }
82
+ else
83
+ throw new ToolError('archive_unsafe', `The template folder has a special file: files/${p}.`, 'Templates hold regular files only.');
84
+ }
85
+ };
86
+ try {
87
+ if (!lstatSync(join(dir, 'files')).isDirectory())
88
+ throw new Error('not a folder');
89
+ }
90
+ catch {
91
+ throw new ToolError('bad_template', `${dir} has no files/ folder.`, 'A template folder has manifest.json, LICENSE and files/.');
92
+ }
93
+ walkDir('');
94
+ checkCollisions([...files.keys()], 'template folder');
95
+ const manifest = parseManifest(manifestBytes.toString('utf8'));
96
+ verifyFiles(manifest, files);
97
+ return { manifest, manifestBytes, license: license.toString('utf8'), files, origin: dir };
98
+ }
@@ -0,0 +1,148 @@
1
+ // A strict JSON parser for data that comes from the network or a template:
2
+ // duplicate keys, deep nesting and oversized strings are errors, not
3
+ // "last one wins". JSON.parse silently keeps the last duplicate, which lets a
4
+ // tampered manifest say one thing to one reader and another to the next.
5
+ import { ToolError } from "../core/types.js";
6
+ export function parseStrictJson(text, what, limits = {}) {
7
+ const maxDepth = limits.maxDepth ?? 32;
8
+ const maxString = limits.maxString ?? 64 * 1024;
9
+ const maxBytes = limits.maxBytes ?? 8 * 1024 * 1024;
10
+ if (text.length > maxBytes)
11
+ throw bad(`${what} is larger than ${maxBytes} bytes.`);
12
+ let i = 0;
13
+ const fail = (msg) => {
14
+ throw bad(`${what} is not valid JSON: ${msg} at offset ${i}.`);
15
+ };
16
+ const ws = () => {
17
+ while (i < text.length && (text[i] === ' ' || text[i] === '\n' || text[i] === '\r' || text[i] === '\t'))
18
+ i++;
19
+ };
20
+ const str = () => {
21
+ if (text[i] !== '"')
22
+ fail('expected a string');
23
+ i++;
24
+ let out = '';
25
+ for (;;) {
26
+ if (i >= text.length)
27
+ fail('unterminated string');
28
+ const c = text[i];
29
+ if (c === '"') {
30
+ i++;
31
+ break;
32
+ }
33
+ if (c < ' ')
34
+ fail('control character in a string');
35
+ if (c === '\\') {
36
+ const e = text[i + 1];
37
+ const map = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
38
+ if (e in map) {
39
+ out += map[e];
40
+ i += 2;
41
+ }
42
+ else if (e === 'u') {
43
+ const hex = text.slice(i + 2, i + 6);
44
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
45
+ fail('bad \\u escape');
46
+ out += String.fromCharCode(parseInt(hex, 16));
47
+ i += 6;
48
+ }
49
+ else
50
+ fail('bad escape');
51
+ }
52
+ else {
53
+ out += c;
54
+ i++;
55
+ }
56
+ if (out.length > maxString)
57
+ fail(`a string longer than ${maxString} characters`);
58
+ }
59
+ return out;
60
+ };
61
+ const value = (depth) => {
62
+ if (depth > maxDepth)
63
+ fail(`nesting deeper than ${maxDepth}`);
64
+ ws();
65
+ const c = text[i];
66
+ if (c === '{') {
67
+ i++;
68
+ const obj = Object.create(null);
69
+ ws();
70
+ if (text[i] === '}') {
71
+ i++;
72
+ return obj;
73
+ }
74
+ for (;;) {
75
+ ws();
76
+ const key = str();
77
+ if (Object.prototype.hasOwnProperty.call(obj, key))
78
+ fail(`duplicate key "${key}"`);
79
+ ws();
80
+ if (text[i] !== ':')
81
+ fail('expected ":"');
82
+ i++;
83
+ obj[key] = value(depth + 1);
84
+ ws();
85
+ if (text[i] === ',') {
86
+ i++;
87
+ continue;
88
+ }
89
+ if (text[i] === '}') {
90
+ i++;
91
+ return obj;
92
+ }
93
+ fail('expected "," or "}"');
94
+ }
95
+ }
96
+ if (c === '[') {
97
+ i++;
98
+ const arr = [];
99
+ ws();
100
+ if (text[i] === ']') {
101
+ i++;
102
+ return arr;
103
+ }
104
+ for (;;) {
105
+ arr.push(value(depth + 1));
106
+ ws();
107
+ if (text[i] === ',') {
108
+ i++;
109
+ continue;
110
+ }
111
+ if (text[i] === ']') {
112
+ i++;
113
+ return arr;
114
+ }
115
+ fail('expected "," or "]"');
116
+ }
117
+ }
118
+ if (c === '"')
119
+ return str();
120
+ if (text.startsWith('true', i))
121
+ return (i += 4), true;
122
+ if (text.startsWith('false', i))
123
+ return (i += 5), false;
124
+ if (text.startsWith('null', i))
125
+ return (i += 4), null;
126
+ const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(text.slice(i, i + 40));
127
+ if (!m)
128
+ fail('unexpected character');
129
+ i += m[0].length;
130
+ return Number(m[0]);
131
+ };
132
+ // A BOM is tolerated; nothing else before or after the value.
133
+ if (text.charCodeAt(0) === 0xfeff)
134
+ i = 1;
135
+ const v = value(0);
136
+ ws();
137
+ if (i !== text.length)
138
+ fail('extra text after the value');
139
+ return v;
140
+ }
141
+ function bad(message) {
142
+ return new ToolError('bad_template', message, 'Report it; this file is not usable as published.');
143
+ }
144
+ /** Strips control and escape characters from text shown in the terminal. */
145
+ export function printable(text) {
146
+ // eslint-disable-next-line no-control-regex
147
+ return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g, '');
148
+ }