create-thally-docs 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-ENQIF6MA.js +164 -0
- package/dist/{chunk-SDZZYPSN.js → chunk-GAFCLXU4.js} +1 -1
- package/dist/chunk-GTGHYJXS.js +160 -0
- package/dist/chunk-KXJT2MG5.js +520 -0
- package/dist/chunk-ORUAMPNF.js +187 -0
- package/dist/chunk-WMHBVXWW.js +65 -0
- package/dist/customize.d.ts +39 -0
- package/dist/customize.js +17 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -5
- package/dist/migrate/index.d.ts +35 -0
- package/dist/migrate/index.js +6 -2
- package/dist/release.d.ts +66 -0
- package/dist/release.js +11 -0
- package/dist/scaffold.d.ts +53 -0
- package/dist/scaffold.js +18 -2
- package/dist/starter-sync.d.ts +68 -0
- package/dist/starter-sync.js +22 -0
- package/dist/starter-update.d.ts +43 -0
- package/dist/starter-update.js +155 -0
- package/package.json +19 -4
- package/dist/chunk-RS6U2GSX.js +0 -846
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
STABLE_SCAFFOLD_RELEASE
|
|
4
|
+
} from "./chunk-WMHBVXWW.js";
|
|
5
|
+
|
|
6
|
+
// src/starter-sync.ts
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import {
|
|
9
|
+
copyFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
lstatSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync
|
|
18
|
+
} from "fs";
|
|
19
|
+
import { dirname, join, relative, resolve, sep } from "path";
|
|
20
|
+
var MAX_MANIFEST_BYTES = 256 * 1024;
|
|
21
|
+
var SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
22
|
+
function normalizedRelativePath(value) {
|
|
23
|
+
if (!value || value.includes("\0") || value.includes("\\") || value.startsWith("/")) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const parts = value.split("/");
|
|
27
|
+
if (parts.some((part) => !part || part === "." || part === "..")) return null;
|
|
28
|
+
return parts.join("/");
|
|
29
|
+
}
|
|
30
|
+
function normalizedRule(value) {
|
|
31
|
+
const isDirectoryRule = value.endsWith("/**");
|
|
32
|
+
const isFilenamePrefixRule = !isDirectoryRule && value.endsWith("*");
|
|
33
|
+
const path = isDirectoryRule ? value.slice(0, -3) : isFilenamePrefixRule ? value.slice(0, -1) : value;
|
|
34
|
+
const normalized = normalizedRelativePath(path);
|
|
35
|
+
if (!normalized || path.includes("*")) return null;
|
|
36
|
+
if (isFilenamePrefixRule) {
|
|
37
|
+
const filenamePrefix = normalized.split("/").at(-1);
|
|
38
|
+
if (!filenamePrefix) return null;
|
|
39
|
+
return `${normalized}*`;
|
|
40
|
+
}
|
|
41
|
+
return isDirectoryRule ? `${normalized}/**` : normalized;
|
|
42
|
+
}
|
|
43
|
+
function parsePathRules(value, field, allowEmpty = false) {
|
|
44
|
+
if (!Array.isArray(value) || !allowEmpty && value.length === 0) {
|
|
45
|
+
throw new Error(`The starter ownership contract requires ${field}.`);
|
|
46
|
+
}
|
|
47
|
+
const rules = [];
|
|
48
|
+
const seen = /* @__PURE__ */ new Set();
|
|
49
|
+
for (const candidate of value) {
|
|
50
|
+
const rule = typeof candidate === "string" ? normalizedRule(candidate) : null;
|
|
51
|
+
if (!rule || seen.has(rule)) {
|
|
52
|
+
throw new Error(`The starter ownership contract contains invalid ${field}.`);
|
|
53
|
+
}
|
|
54
|
+
seen.add(rule);
|
|
55
|
+
rules.push(rule);
|
|
56
|
+
}
|
|
57
|
+
return rules;
|
|
58
|
+
}
|
|
59
|
+
function parseStarterOwnershipContract(value) {
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
61
|
+
throw new Error("The starter ownership contract is invalid.");
|
|
62
|
+
}
|
|
63
|
+
const candidate = value;
|
|
64
|
+
return {
|
|
65
|
+
frameworkSyncEligible: parsePathRules(
|
|
66
|
+
candidate.frameworkSyncEligible,
|
|
67
|
+
"frameworkSyncEligible"
|
|
68
|
+
),
|
|
69
|
+
userOwnedNeverOverwrite: parsePathRules(
|
|
70
|
+
candidate.userOwnedNeverOverwrite,
|
|
71
|
+
"userOwnedNeverOverwrite"
|
|
72
|
+
),
|
|
73
|
+
manualReview: parsePathRules(candidate.manualReview, "manualReview", true)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function mergeStarterOwnershipContracts(previous, next) {
|
|
77
|
+
const oldContract = parseStarterOwnershipContract(previous);
|
|
78
|
+
const newContract = parseStarterOwnershipContract(next);
|
|
79
|
+
const unique = (values) => [...new Set(values)];
|
|
80
|
+
return {
|
|
81
|
+
frameworkSyncEligible: unique([
|
|
82
|
+
...oldContract.frameworkSyncEligible,
|
|
83
|
+
...newContract.frameworkSyncEligible
|
|
84
|
+
]),
|
|
85
|
+
userOwnedNeverOverwrite: unique([
|
|
86
|
+
...oldContract.userOwnedNeverOverwrite,
|
|
87
|
+
...newContract.userOwnedNeverOverwrite,
|
|
88
|
+
STABLE_SCAFFOLD_RELEASE.source.manifestPath
|
|
89
|
+
]),
|
|
90
|
+
manualReview: unique([
|
|
91
|
+
...oldContract.manualReview,
|
|
92
|
+
...newContract.manualReview
|
|
93
|
+
])
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function starterManifestSha256(source) {
|
|
97
|
+
return createHash("sha256").update(source, "utf8").digest("hex");
|
|
98
|
+
}
|
|
99
|
+
function parseStringRecord(value, field) {
|
|
100
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
101
|
+
throw new Error(`The starter release manifest contains invalid ${field}.`);
|
|
102
|
+
}
|
|
103
|
+
const entries = Object.entries(value);
|
|
104
|
+
if (entries.length === 0 || entries.some(([key, item]) => !key || typeof item !== "string")) {
|
|
105
|
+
throw new Error(`The starter release manifest contains invalid ${field}.`);
|
|
106
|
+
}
|
|
107
|
+
return Object.fromEntries(entries);
|
|
108
|
+
}
|
|
109
|
+
function parseStarterReleaseManifest(source, release = STABLE_SCAFFOLD_RELEASE) {
|
|
110
|
+
if (!source || Buffer.byteLength(source, "utf8") > MAX_MANIFEST_BYTES) {
|
|
111
|
+
throw new Error("The starter release manifest is missing or too large.");
|
|
112
|
+
}
|
|
113
|
+
const expectedHash = release.source.manifestSha256;
|
|
114
|
+
if (!SHA256_PATTERN.test(expectedHash) || starterManifestSha256(source) !== expectedHash) {
|
|
115
|
+
throw new Error("The starter release manifest does not match its promoted SHA-256.");
|
|
116
|
+
}
|
|
117
|
+
let value;
|
|
118
|
+
try {
|
|
119
|
+
value = JSON.parse(source);
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error("The starter release manifest is invalid JSON.");
|
|
122
|
+
}
|
|
123
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
124
|
+
throw new Error("The starter release manifest is invalid.");
|
|
125
|
+
}
|
|
126
|
+
const candidate = value;
|
|
127
|
+
if (candidate.schemaVersion !== 1 || !Number.isSafeInteger(candidate.starterVersion) || Number(candidate.starterVersion) < 1 || candidate.starterVersion !== release.starterVersion || candidate.repository !== release.source.repository || candidate.defaultBranch !== "main" || !candidate.runtime || candidate.runtime.repository !== release.runtime.repository || candidate.runtime.commitSha !== release.runtime.commitSha || candidate.runtime.treeSha !== release.runtime.treeSha) {
|
|
128
|
+
throw new Error("The starter release manifest identity does not match its release.");
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
starterVersion: candidate.starterVersion,
|
|
133
|
+
repository: candidate.repository,
|
|
134
|
+
defaultBranch: candidate.defaultBranch,
|
|
135
|
+
runtime: {
|
|
136
|
+
repository: candidate.runtime.repository,
|
|
137
|
+
commitSha: candidate.runtime.commitSha,
|
|
138
|
+
treeSha: candidate.runtime.treeSha
|
|
139
|
+
},
|
|
140
|
+
packages: parseStringRecord(candidate.packages, "packages"),
|
|
141
|
+
ownership: parseStarterOwnershipContract(candidate.ownership)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function readStarterReleaseManifest(starterDir, release = STABLE_SCAFFOLD_RELEASE) {
|
|
145
|
+
const manifest = parseStarterReleaseManifest(
|
|
146
|
+
readFileSync(join(starterDir, release.source.manifestPath), "utf8"),
|
|
147
|
+
release
|
|
148
|
+
);
|
|
149
|
+
let packageValue;
|
|
150
|
+
try {
|
|
151
|
+
packageValue = JSON.parse(readFileSync(join(starterDir, "package.json"), "utf8"));
|
|
152
|
+
} catch {
|
|
153
|
+
throw new Error("The stable Thally starter contains invalid package.json.");
|
|
154
|
+
}
|
|
155
|
+
if (!packageValue || typeof packageValue !== "object" || Array.isArray(packageValue)) {
|
|
156
|
+
throw new Error("The stable Thally starter contains invalid package.json.");
|
|
157
|
+
}
|
|
158
|
+
const packageJson = packageValue;
|
|
159
|
+
const declaredPackages = {
|
|
160
|
+
...packageJson.dependencies,
|
|
161
|
+
...packageJson.devDependencies
|
|
162
|
+
};
|
|
163
|
+
for (const [name, version] of Object.entries(manifest.packages)) {
|
|
164
|
+
if (declaredPackages[name] !== version) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`The starter release manifest package ${name} does not match package.json.`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return manifest;
|
|
171
|
+
}
|
|
172
|
+
function matchesRule(path, rule) {
|
|
173
|
+
if (rule.endsWith("/**")) {
|
|
174
|
+
const root = rule.slice(0, -3);
|
|
175
|
+
return path === root || path.startsWith(`${root}/`);
|
|
176
|
+
}
|
|
177
|
+
if (rule.endsWith("*")) {
|
|
178
|
+
const prefix = rule.slice(0, -1);
|
|
179
|
+
if (!path.startsWith(prefix)) return false;
|
|
180
|
+
return !path.slice(prefix.length).includes("/");
|
|
181
|
+
}
|
|
182
|
+
return path === rule;
|
|
183
|
+
}
|
|
184
|
+
function ruleRoot(rule) {
|
|
185
|
+
if (rule.endsWith("/**")) return rule.slice(0, -3);
|
|
186
|
+
if (!rule.endsWith("*")) return rule;
|
|
187
|
+
const prefix = rule.slice(0, -1);
|
|
188
|
+
const slash = prefix.lastIndexOf("/");
|
|
189
|
+
return slash === -1 ? "" : prefix.slice(0, slash);
|
|
190
|
+
}
|
|
191
|
+
function classifyStarterPath(path, contract) {
|
|
192
|
+
const normalized = normalizedRelativePath(path);
|
|
193
|
+
if (!normalized) throw new Error("Cannot classify an unsafe starter path.");
|
|
194
|
+
const parsed = parseStarterOwnershipContract(contract);
|
|
195
|
+
if (parsed.userOwnedNeverOverwrite.some((rule) => matchesRule(normalized, rule))) {
|
|
196
|
+
return "protected";
|
|
197
|
+
}
|
|
198
|
+
if (parsed.manualReview.some((rule) => matchesRule(normalized, rule))) {
|
|
199
|
+
return "manual";
|
|
200
|
+
}
|
|
201
|
+
if (parsed.frameworkSyncEligible.some((rule) => matchesRule(normalized, rule))) {
|
|
202
|
+
return "syncable";
|
|
203
|
+
}
|
|
204
|
+
return "unmanaged";
|
|
205
|
+
}
|
|
206
|
+
function filesystemPathToRepositoryPath(root, path) {
|
|
207
|
+
return relative(root, path).split(sep).join("/");
|
|
208
|
+
}
|
|
209
|
+
function collectFiles(root, contract, desiredOwnership) {
|
|
210
|
+
const files = /* @__PURE__ */ new Map();
|
|
211
|
+
const visited = /* @__PURE__ */ new Set();
|
|
212
|
+
const rules = desiredOwnership === "syncable" ? contract.frameworkSyncEligible : contract.manualReview;
|
|
213
|
+
const visit = (absolutePath) => {
|
|
214
|
+
const resolvedPath = resolve(absolutePath);
|
|
215
|
+
if (visited.has(resolvedPath)) return;
|
|
216
|
+
visited.add(resolvedPath);
|
|
217
|
+
const repositoryPath = filesystemPathToRepositoryPath(root, resolvedPath);
|
|
218
|
+
const ownership = classifyStarterPath(repositoryPath, contract);
|
|
219
|
+
if (ownership === "protected") return;
|
|
220
|
+
const entry = lstatSync(resolvedPath);
|
|
221
|
+
if (entry.isSymbolicLink()) {
|
|
222
|
+
throw new Error("Starter synchronization does not follow symbolic links.");
|
|
223
|
+
}
|
|
224
|
+
if (entry.isFile()) {
|
|
225
|
+
if (ownership === desiredOwnership) files.set(repositoryPath, resolvedPath);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!entry.isDirectory()) {
|
|
229
|
+
throw new Error("Starter synchronization supports only files and directories.");
|
|
230
|
+
}
|
|
231
|
+
for (const child of readdirSync(resolvedPath)) visit(join(resolvedPath, child));
|
|
232
|
+
};
|
|
233
|
+
for (const rule of rules) {
|
|
234
|
+
const rootPath = join(root, ruleRoot(rule));
|
|
235
|
+
if (!existsSync(rootPath)) continue;
|
|
236
|
+
if (rule.endsWith("*") && !rule.endsWith("/**")) {
|
|
237
|
+
for (const child of readdirSync(rootPath)) {
|
|
238
|
+
const childPath = join(rootPath, child);
|
|
239
|
+
const repositoryPath = filesystemPathToRepositoryPath(root, childPath);
|
|
240
|
+
if (matchesRule(repositoryPath, rule)) visit(childPath);
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
visit(rootPath);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return files;
|
|
247
|
+
}
|
|
248
|
+
function sameFile(left, right) {
|
|
249
|
+
if (!left || !right) return left === right;
|
|
250
|
+
return readFileSync(left).equals(readFileSync(right));
|
|
251
|
+
}
|
|
252
|
+
function fileSha256(path) {
|
|
253
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
254
|
+
}
|
|
255
|
+
function captureTargetPrecondition(path) {
|
|
256
|
+
if (!existsSync(path)) return { kind: "missing" };
|
|
257
|
+
const entry = lstatSync(path);
|
|
258
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
259
|
+
throw new Error("Starter synchronization target mutations must be regular files.");
|
|
260
|
+
}
|
|
261
|
+
return { kind: "file", sha256: fileSha256(path) };
|
|
262
|
+
}
|
|
263
|
+
function matchesTargetPrecondition(path, precondition) {
|
|
264
|
+
if (precondition.kind === "missing") return !existsSync(path);
|
|
265
|
+
if (!existsSync(path)) return false;
|
|
266
|
+
const entry = lstatSync(path);
|
|
267
|
+
return entry.isFile() && !entry.isSymbolicLink() && fileSha256(path) === precondition.sha256;
|
|
268
|
+
}
|
|
269
|
+
function planStarterRuntimeSync(oldStarterDir, newStarterDir, targetDir, ownership) {
|
|
270
|
+
const contract = parseStarterOwnershipContract(ownership);
|
|
271
|
+
const oldFiles = collectFiles(resolve(oldStarterDir), contract, "syncable");
|
|
272
|
+
const newFiles = collectFiles(resolve(newStarterDir), contract, "syncable");
|
|
273
|
+
const targetFiles = collectFiles(resolve(targetDir), contract, "syncable");
|
|
274
|
+
const manualReviewPaths = /* @__PURE__ */ new Set([
|
|
275
|
+
...collectFiles(resolve(oldStarterDir), contract, "manual").keys(),
|
|
276
|
+
...collectFiles(resolve(newStarterDir), contract, "manual").keys(),
|
|
277
|
+
...collectFiles(resolve(targetDir), contract, "manual").keys()
|
|
278
|
+
]);
|
|
279
|
+
const copyPaths = [];
|
|
280
|
+
const deletePaths = [];
|
|
281
|
+
const conflictPaths = [];
|
|
282
|
+
const preservedPaths = [];
|
|
283
|
+
const unchangedPaths = [];
|
|
284
|
+
const paths = /* @__PURE__ */ new Set([
|
|
285
|
+
...oldFiles.keys(),
|
|
286
|
+
...newFiles.keys(),
|
|
287
|
+
...targetFiles.keys()
|
|
288
|
+
]);
|
|
289
|
+
for (const path of paths) {
|
|
290
|
+
const oldPath = oldFiles.get(path);
|
|
291
|
+
const newPath = newFiles.get(path);
|
|
292
|
+
const targetPath = targetFiles.get(path);
|
|
293
|
+
const targetMatchesOld = sameFile(targetPath, oldPath);
|
|
294
|
+
const newMatchesOld = sameFile(newPath, oldPath);
|
|
295
|
+
const targetMatchesNew = sameFile(targetPath, newPath);
|
|
296
|
+
if (!oldPath) {
|
|
297
|
+
if (!newPath) preservedPaths.push(path);
|
|
298
|
+
else if (!targetPath) copyPaths.push(path);
|
|
299
|
+
else if (targetMatchesNew) unchangedPaths.push(path);
|
|
300
|
+
else conflictPaths.push(path);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (targetMatchesOld) {
|
|
304
|
+
if (!newPath) deletePaths.push(path);
|
|
305
|
+
else if (newMatchesOld) unchangedPaths.push(path);
|
|
306
|
+
else copyPaths.push(path);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (newMatchesOld || targetMatchesNew || !targetPath && !newPath) {
|
|
310
|
+
preservedPaths.push(path);
|
|
311
|
+
} else {
|
|
312
|
+
conflictPaths.push(path);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const sortedCopyPaths = copyPaths.sort();
|
|
316
|
+
const sortedDeletePaths = deletePaths.sort();
|
|
317
|
+
const targetPreconditions = Object.fromEntries(
|
|
318
|
+
[...sortedCopyPaths, ...sortedDeletePaths].map((path) => [
|
|
319
|
+
path,
|
|
320
|
+
captureTargetPrecondition(ensureContained(targetDir, path))
|
|
321
|
+
])
|
|
322
|
+
);
|
|
323
|
+
return {
|
|
324
|
+
copyPaths: sortedCopyPaths,
|
|
325
|
+
deletePaths: sortedDeletePaths,
|
|
326
|
+
conflictPaths: conflictPaths.sort(),
|
|
327
|
+
manualReviewPaths: [...manualReviewPaths].sort(),
|
|
328
|
+
preservedPaths: preservedPaths.sort(),
|
|
329
|
+
unchangedPaths: unchangedPaths.sort(),
|
|
330
|
+
targetPreconditions
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function ensureContained(root, repositoryPath) {
|
|
334
|
+
const target = resolve(root, repositoryPath);
|
|
335
|
+
const prefix = `${resolve(root)}${sep}`;
|
|
336
|
+
if (!target.startsWith(prefix)) {
|
|
337
|
+
throw new Error("Starter synchronization resolved outside its target.");
|
|
338
|
+
}
|
|
339
|
+
return target;
|
|
340
|
+
}
|
|
341
|
+
function assertSafeTargetParents(root, path) {
|
|
342
|
+
const resolvedRoot = resolve(root);
|
|
343
|
+
let parent = dirname(resolve(path));
|
|
344
|
+
while (parent !== resolvedRoot) {
|
|
345
|
+
if (!parent.startsWith(`${resolvedRoot}${sep}`)) {
|
|
346
|
+
throw new Error("Starter synchronization parent resolved outside its target.");
|
|
347
|
+
}
|
|
348
|
+
if (existsSync(parent)) {
|
|
349
|
+
const entry = lstatSync(parent);
|
|
350
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
351
|
+
throw new Error("Starter synchronization refuses unsafe target parents.");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
parent = dirname(parent);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function applyStarterRuntimeSyncPlan(newStarterDir, targetDir, plan, ownership, options) {
|
|
358
|
+
const contract = parseStarterOwnershipContract(ownership);
|
|
359
|
+
if (!options.confirmed) {
|
|
360
|
+
throw new Error("Review and confirm the starter synchronization plan first.");
|
|
361
|
+
}
|
|
362
|
+
if (plan.conflictPaths.length > 0) {
|
|
363
|
+
throw new Error("Resolve starter synchronization conflicts before applying.");
|
|
364
|
+
}
|
|
365
|
+
const uniqueMutations = /* @__PURE__ */ new Set([...plan.copyPaths, ...plan.deletePaths]);
|
|
366
|
+
if (uniqueMutations.size !== plan.copyPaths.length + plan.deletePaths.length) {
|
|
367
|
+
throw new Error("Starter synchronization plan contains conflicting mutations.");
|
|
368
|
+
}
|
|
369
|
+
for (const path of uniqueMutations) {
|
|
370
|
+
if (classifyStarterPath(path, contract) !== "syncable") {
|
|
371
|
+
throw new Error(`Starter synchronization refused owner path ${path}.`);
|
|
372
|
+
}
|
|
373
|
+
if (!plan.targetPreconditions[path]) {
|
|
374
|
+
throw new Error(`Starter synchronization plan lacks a target precondition for ${path}.`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const targetRoot = resolve(targetDir);
|
|
378
|
+
const targetRootEntry = lstatSync(targetRoot);
|
|
379
|
+
if (!targetRootEntry.isDirectory() || targetRootEntry.isSymbolicLink()) {
|
|
380
|
+
throw new Error("Starter synchronization requires a regular target directory.");
|
|
381
|
+
}
|
|
382
|
+
if (options.provenance) {
|
|
383
|
+
const expectedTarget = ensureContained(
|
|
384
|
+
targetDir,
|
|
385
|
+
STABLE_SCAFFOLD_RELEASE.source.manifestPath
|
|
386
|
+
);
|
|
387
|
+
const expectedSource = ensureContained(
|
|
388
|
+
newStarterDir,
|
|
389
|
+
STABLE_SCAFFOLD_RELEASE.source.manifestPath
|
|
390
|
+
);
|
|
391
|
+
if (resolve(options.provenance.targetPath) !== expectedTarget || resolve(options.provenance.sourcePath) !== expectedSource) {
|
|
392
|
+
throw new Error("Starter synchronization received unsafe provenance paths.");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const transactionDir = mkdtempSync(join(dirname(targetRoot), ".thally-starter-transaction-"));
|
|
396
|
+
const stagedDir = join(transactionDir, "staged");
|
|
397
|
+
const backupDir = join(transactionDir, "backup");
|
|
398
|
+
mkdirSync(stagedDir);
|
|
399
|
+
mkdirSync(backupDir);
|
|
400
|
+
const applied = [];
|
|
401
|
+
let mutationIndex = 0;
|
|
402
|
+
const stagePath = (path) => {
|
|
403
|
+
const stagedPath = ensureContained(stagedDir, path);
|
|
404
|
+
mkdirSync(dirname(stagedPath), { recursive: true });
|
|
405
|
+
return stagedPath;
|
|
406
|
+
};
|
|
407
|
+
const backupPath = (path) => {
|
|
408
|
+
const pathInBackup = ensureContained(backupDir, path);
|
|
409
|
+
mkdirSync(dirname(pathInBackup), { recursive: true });
|
|
410
|
+
return pathInBackup;
|
|
411
|
+
};
|
|
412
|
+
const mutate = (path, targetPath, precondition, replacementPath) => {
|
|
413
|
+
assertSafeTargetParents(targetDir, targetPath);
|
|
414
|
+
if (!matchesTargetPrecondition(targetPath, precondition)) {
|
|
415
|
+
throw new Error(`Starter synchronization target changed after planning: ${path}.`);
|
|
416
|
+
}
|
|
417
|
+
if (precondition.kind === "file") {
|
|
418
|
+
copyFileSync(targetPath, backupPath(path));
|
|
419
|
+
}
|
|
420
|
+
applied.push({ path, targetPath, precondition });
|
|
421
|
+
if (replacementPath) {
|
|
422
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
423
|
+
renameSync(replacementPath, targetPath);
|
|
424
|
+
} else {
|
|
425
|
+
rmSync(targetPath, { force: true });
|
|
426
|
+
}
|
|
427
|
+
options.onMutationApplied?.(path, mutationIndex);
|
|
428
|
+
mutationIndex += 1;
|
|
429
|
+
};
|
|
430
|
+
try {
|
|
431
|
+
for (const path of plan.copyPaths) {
|
|
432
|
+
const sourcePath = ensureContained(newStarterDir, path);
|
|
433
|
+
if (!existsSync(sourcePath) || !lstatSync(sourcePath).isFile()) {
|
|
434
|
+
throw new Error(`Starter synchronization source is missing ${path}.`);
|
|
435
|
+
}
|
|
436
|
+
copyFileSync(sourcePath, stagePath(path));
|
|
437
|
+
}
|
|
438
|
+
let provenanceStagePath;
|
|
439
|
+
if (options.provenance) {
|
|
440
|
+
if (!existsSync(options.provenance.sourcePath) || !lstatSync(options.provenance.sourcePath).isFile()) {
|
|
441
|
+
throw new Error("Starter synchronization provenance source is missing.");
|
|
442
|
+
}
|
|
443
|
+
provenanceStagePath = join(transactionDir, "next-provenance");
|
|
444
|
+
copyFileSync(options.provenance.sourcePath, provenanceStagePath);
|
|
445
|
+
}
|
|
446
|
+
for (const path of uniqueMutations) {
|
|
447
|
+
const targetPath = ensureContained(targetDir, path);
|
|
448
|
+
assertSafeTargetParents(targetDir, targetPath);
|
|
449
|
+
if (!matchesTargetPrecondition(targetPath, plan.targetPreconditions[path])) {
|
|
450
|
+
throw new Error(`Starter synchronization target changed after planning: ${path}.`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (options.provenance) {
|
|
454
|
+
assertSafeTargetParents(targetDir, options.provenance.targetPath);
|
|
455
|
+
if (fileSha256(options.provenance.targetPath) !== options.provenance.expectedSha256) {
|
|
456
|
+
throw new Error("The project starter manifest changed during update planning.");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const path of plan.deletePaths) {
|
|
460
|
+
mutate(
|
|
461
|
+
path,
|
|
462
|
+
ensureContained(targetDir, path),
|
|
463
|
+
plan.targetPreconditions[path]
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
for (const path of plan.copyPaths) {
|
|
467
|
+
mutate(
|
|
468
|
+
path,
|
|
469
|
+
ensureContained(targetDir, path),
|
|
470
|
+
plan.targetPreconditions[path],
|
|
471
|
+
ensureContained(stagedDir, path)
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (options.provenance && provenanceStagePath) {
|
|
475
|
+
const provenancePrecondition = {
|
|
476
|
+
kind: "file",
|
|
477
|
+
sha256: options.provenance.expectedSha256
|
|
478
|
+
};
|
|
479
|
+
mutate(
|
|
480
|
+
STABLE_SCAFFOLD_RELEASE.source.manifestPath,
|
|
481
|
+
options.provenance.targetPath,
|
|
482
|
+
provenancePrecondition,
|
|
483
|
+
provenanceStagePath
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
} catch (error) {
|
|
487
|
+
let rollbackError;
|
|
488
|
+
for (const mutation of applied.reverse()) {
|
|
489
|
+
try {
|
|
490
|
+
rmSync(mutation.targetPath, { force: true });
|
|
491
|
+
if (mutation.precondition.kind === "file") {
|
|
492
|
+
mkdirSync(dirname(mutation.targetPath), { recursive: true });
|
|
493
|
+
renameSync(backupPath(mutation.path), mutation.targetPath);
|
|
494
|
+
}
|
|
495
|
+
} catch (candidate) {
|
|
496
|
+
rollbackError ??= candidate;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (rollbackError) {
|
|
500
|
+
throw new AggregateError(
|
|
501
|
+
[error, rollbackError],
|
|
502
|
+
"Starter synchronization failed and rollback was incomplete."
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
throw error;
|
|
506
|
+
} finally {
|
|
507
|
+
rmSync(transactionDir, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export {
|
|
512
|
+
parseStarterOwnershipContract,
|
|
513
|
+
mergeStarterOwnershipContracts,
|
|
514
|
+
starterManifestSha256,
|
|
515
|
+
parseStarterReleaseManifest,
|
|
516
|
+
readStarterReleaseManifest,
|
|
517
|
+
classifyStarterPath,
|
|
518
|
+
planStarterRuntimeSync,
|
|
519
|
+
applyStarterRuntimeSyncPlan
|
|
520
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/customize.ts
|
|
4
|
+
import { cpSync, existsSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
function readJsonObject(filePath, label) {
|
|
7
|
+
let value;
|
|
8
|
+
try {
|
|
9
|
+
value = JSON.parse(readFileSync(filePath, "utf8"));
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`The stable Thally starter contains invalid ${label}.`);
|
|
12
|
+
}
|
|
13
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
+
throw new Error(`The stable Thally starter contains invalid ${label}.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function normalizedStarterLocales(locales) {
|
|
22
|
+
const normalized = [{ code: "en", label: "English" }];
|
|
23
|
+
const seen = /* @__PURE__ */ new Set(["en"]);
|
|
24
|
+
for (const locale of locales ?? []) {
|
|
25
|
+
const code = locale.code.trim().toLowerCase();
|
|
26
|
+
const label = locale.label.trim();
|
|
27
|
+
if (!/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/.test(code) || !label || label.length > 80 || seen.has(code)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
seen.add(code);
|
|
31
|
+
normalized.push({ code, label });
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
function updateStarterDocsConfig(targetDir, enableAiChat, repoUrl, i18nLocales) {
|
|
36
|
+
const configPath = join(targetDir, "docs.json");
|
|
37
|
+
const config = readJsonObject(configPath, "docs.json");
|
|
38
|
+
const ai = isRecord(config.ai) ? { ...config.ai } : {};
|
|
39
|
+
ai.chat = enableAiChat;
|
|
40
|
+
config.ai = ai;
|
|
41
|
+
const navbar = isRecord(config.navbar) ? { ...config.navbar } : {};
|
|
42
|
+
const existingLinks = Array.isArray(navbar.links) ? navbar.links : [];
|
|
43
|
+
const links = existingLinks.filter(
|
|
44
|
+
(link) => !isRecord(link) || link.type !== "github" && link.label !== "GitHub"
|
|
45
|
+
);
|
|
46
|
+
if (repoUrl) {
|
|
47
|
+
links.push({ label: "GitHub", href: repoUrl, type: "github" });
|
|
48
|
+
}
|
|
49
|
+
if (links.length > 0) navbar.links = links;
|
|
50
|
+
else delete navbar.links;
|
|
51
|
+
if (Object.keys(navbar).length > 0) config.navbar = navbar;
|
|
52
|
+
else delete config.navbar;
|
|
53
|
+
config.i18n = {
|
|
54
|
+
defaultLocale: "en",
|
|
55
|
+
locales: normalizedStarterLocales(i18nLocales)
|
|
56
|
+
};
|
|
57
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
|
|
58
|
+
`, "utf8");
|
|
59
|
+
}
|
|
60
|
+
function escapeTypeScriptString(value) {
|
|
61
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
62
|
+
}
|
|
63
|
+
function replaceRequired(source, pattern, replacement, field) {
|
|
64
|
+
if (!pattern.test(source)) {
|
|
65
|
+
throw new Error(`The stable Thally starter is missing owner field ${field}.`);
|
|
66
|
+
}
|
|
67
|
+
return source.replace(pattern, replacement);
|
|
68
|
+
}
|
|
69
|
+
function updateSiteConfig(targetDir, projectName, description, brandPreset, repoUrl) {
|
|
70
|
+
if (!projectName.trim() || /[\0\r\n]/.test(projectName)) {
|
|
71
|
+
throw new Error("The documentation project name is invalid.");
|
|
72
|
+
}
|
|
73
|
+
if (!["primary", "secondary"].includes(brandPreset)) {
|
|
74
|
+
throw new Error("The documentation brand preset is invalid.");
|
|
75
|
+
}
|
|
76
|
+
const siteFile = join(targetDir, "src", "data", "site.ts");
|
|
77
|
+
if (!existsSync(siteFile)) {
|
|
78
|
+
throw new Error("The stable Thally starter is missing src/data/site.ts.");
|
|
79
|
+
}
|
|
80
|
+
const escapedName = escapeTypeScriptString(projectName);
|
|
81
|
+
const escapedDescription = escapeTypeScriptString(description);
|
|
82
|
+
const escapedRepoUrl = escapeTypeScriptString(repoUrl);
|
|
83
|
+
let source = readFileSync(siteFile, "utf8");
|
|
84
|
+
source = replaceRequired(
|
|
85
|
+
source,
|
|
86
|
+
/name:[ \t]*'(?:\\.|[^'\\\r\n])*'/,
|
|
87
|
+
`name: '${escapedName}'`,
|
|
88
|
+
"site.name"
|
|
89
|
+
);
|
|
90
|
+
source = replaceRequired(
|
|
91
|
+
source,
|
|
92
|
+
/description:[ \t]*(?:\r?\n[ \t]*)?'(?:\\.|[^'\\\r\n])*'/,
|
|
93
|
+
`description:
|
|
94
|
+
'${escapedDescription}'`,
|
|
95
|
+
"site.description"
|
|
96
|
+
);
|
|
97
|
+
source = replaceRequired(
|
|
98
|
+
source,
|
|
99
|
+
/const brandPreset:[ \t]*BrandPresetKey[ \t]*=[ \t]*'(?:\\.|[^'\\\r\n])*'/,
|
|
100
|
+
`const brandPreset: BrandPresetKey = '${brandPreset}'`,
|
|
101
|
+
"site.brandPreset"
|
|
102
|
+
);
|
|
103
|
+
source = replaceRequired(
|
|
104
|
+
source,
|
|
105
|
+
/repoUrl:[ \t]*'(?:\\.|[^'\\\r\n])*'/,
|
|
106
|
+
`repoUrl: '${escapedRepoUrl}'`,
|
|
107
|
+
"site.repoUrl"
|
|
108
|
+
);
|
|
109
|
+
source = source.replace(
|
|
110
|
+
/\{[ \t]*label:[ \t]*'GitHub',[ \t]*href:[ \t]*'(?:\\.|[^'\\\r\n])*'[ \t]*\}/,
|
|
111
|
+
`{ label: 'GitHub', href: '${escapedRepoUrl}' }`
|
|
112
|
+
);
|
|
113
|
+
source = source.replace(
|
|
114
|
+
/\{[ \t]*label:[ \t]*'Support',[ \t]*href:[ \t]*'(?:\\.|[^'\\\r\n])*'[ \t]*\}/,
|
|
115
|
+
`{ label: 'Support', href: '${escapedRepoUrl ? `${escapedRepoUrl}/issues/new` : ""}' }`
|
|
116
|
+
);
|
|
117
|
+
if (!repoUrl) {
|
|
118
|
+
source = source.replace(
|
|
119
|
+
/\r?\n[ \t]*\{[ \t]*label:[ \t]*'(?:GitHub|Support)',[ \t]*href:[ \t]*''[ \t]*\},?/g,
|
|
120
|
+
""
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
writeFileSync(siteFile, source, "utf8");
|
|
124
|
+
}
|
|
125
|
+
function updatePackageIdentity(targetDir, packageName) {
|
|
126
|
+
const packagePath = join(targetDir, "package.json");
|
|
127
|
+
const packageJson = readJsonObject(packagePath, "package.json");
|
|
128
|
+
packageJson.name = packageName;
|
|
129
|
+
writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}
|
|
130
|
+
`, "utf8");
|
|
131
|
+
const lockPath = join(targetDir, "package-lock.json");
|
|
132
|
+
if (!existsSync(lockPath)) return;
|
|
133
|
+
const lock = readJsonObject(lockPath, "package-lock.json");
|
|
134
|
+
lock.name = packageName;
|
|
135
|
+
if (isRecord(lock.packages) && isRecord(lock.packages[""])) {
|
|
136
|
+
lock.packages[""].name = packageName;
|
|
137
|
+
}
|
|
138
|
+
writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}
|
|
139
|
+
`, "utf8");
|
|
140
|
+
}
|
|
141
|
+
function updateCloudflareRuntimeName(targetDir, packageName) {
|
|
142
|
+
const configPath = join(targetDir, "wrangler.jsonc");
|
|
143
|
+
if (!existsSync(configPath)) {
|
|
144
|
+
throw new Error("The stable Thally starter is missing wrangler.jsonc.");
|
|
145
|
+
}
|
|
146
|
+
const source = readFileSync(configPath, "utf8");
|
|
147
|
+
const pattern = /("name"\s*:\s*)"(?:\\.|[^"\\])*"/;
|
|
148
|
+
const updated = replaceRequired(
|
|
149
|
+
source,
|
|
150
|
+
pattern,
|
|
151
|
+
`$1${JSON.stringify(packageName)}`,
|
|
152
|
+
"wrangler.name"
|
|
153
|
+
);
|
|
154
|
+
writeFileSync(configPath, updated, "utf8");
|
|
155
|
+
}
|
|
156
|
+
function updateEnvExample(targetDir) {
|
|
157
|
+
const envFile = join(targetDir, ".env.example");
|
|
158
|
+
if (!existsSync(envFile)) return;
|
|
159
|
+
const envLocal = join(targetDir, ".env.local");
|
|
160
|
+
if (!existsSync(envLocal)) cpSync(envFile, envLocal);
|
|
161
|
+
}
|
|
162
|
+
function personalizeStarter(targetDir, options) {
|
|
163
|
+
updateStarterDocsConfig(
|
|
164
|
+
targetDir,
|
|
165
|
+
options.enableAiChat,
|
|
166
|
+
options.repoUrl,
|
|
167
|
+
options.i18nLocales
|
|
168
|
+
);
|
|
169
|
+
updateSiteConfig(
|
|
170
|
+
targetDir,
|
|
171
|
+
options.projectName,
|
|
172
|
+
options.description,
|
|
173
|
+
options.brandPreset,
|
|
174
|
+
options.repoUrl
|
|
175
|
+
);
|
|
176
|
+
updatePackageIdentity(targetDir, options.packageName);
|
|
177
|
+
updateCloudflareRuntimeName(targetDir, options.packageName);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export {
|
|
181
|
+
updateStarterDocsConfig,
|
|
182
|
+
updateSiteConfig,
|
|
183
|
+
updatePackageIdentity,
|
|
184
|
+
updateCloudflareRuntimeName,
|
|
185
|
+
updateEnvExample,
|
|
186
|
+
personalizeStarter
|
|
187
|
+
};
|