create-thally-docs 0.9.0 → 0.10.1

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.
@@ -1,83 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- patchApiReferenceGuard,
4
- patchGitignore,
5
- patchOpenApiFetch,
6
- patchPackageJson,
7
- patchTopBarNavigation,
8
- updateEnvExample,
9
- updateSiteConfig,
10
- writeCloudflareRuntimeConfig,
11
- writeStarterAgentGuide,
12
- writeStarterContent,
13
- writeStarterReadme
14
- } from "./chunk-BW7J7DJV.js";
3
+ personalizeStarter,
4
+ updateEnvExample
5
+ } from "./chunk-ORUAMPNF.js";
15
6
  import {
16
- STABLE_SCAFFOLD_RELEASE
17
- } from "./chunk-PJW4JJIT.js";
7
+ downloadStarter
8
+ } from "./chunk-TMXBINSI.js";
18
9
 
19
10
  // src/scaffold.ts
20
11
  import { existsSync, mkdirSync, readdirSync } from "fs";
21
12
  import { resolve } from "path";
22
13
 
23
- // src/download.ts
24
- import { Readable, pipeline } from "stream";
25
- import { promisify } from "util";
26
- import { extract } from "tar";
27
- var pipelineAsync = promisify(pipeline);
28
- var TEMPLATE_REPOSITORY = STABLE_SCAFFOLD_RELEASE.source.repository;
29
- var TEMPLATE_COMMIT_SHA = STABLE_SCAFFOLD_RELEASE.source.commitSha;
30
- var TARBALL_URL = STABLE_SCAFFOLD_RELEASE.source.archiveUrl;
31
- var EXCLUDE_PATHS = [
32
- // Match both the directory entry itself and every nested file. Tar invokes
33
- // the filter for `.../node_modules` before descendants, without a trailing `/`.
34
- "/node_modules",
35
- // The canonical docs repository may temporarily retain package sources while
36
- // runtime work is being upstreamed. A scaffold consumes the published
37
- // packages declared in package.json; it must never inherit those sources.
38
- "/packages",
39
- "/.git/",
40
- "/.next/",
41
- "/.data/",
42
- "/.thally/",
43
- // The first-party docs deployment pins its own canonical origin.
44
- "/.env.production",
45
- "/thally-track.yml",
46
- "/CODEOWNERS",
47
- "/CLAUDE.md",
48
- "/notes/",
49
- "/public/images/",
50
- "/src/public/",
51
- "/snippets/",
52
- "/.github/ISSUE_TEMPLATE/",
53
- "/.github/PULL_REQUEST_TEMPLATE.md",
54
- "/README.md"
55
- ];
56
- function shouldInclude(path) {
57
- for (const excluded of EXCLUDE_PATHS) {
58
- if (path.includes(excluded)) {
59
- return false;
60
- }
61
- }
62
- return true;
63
- }
64
- async function downloadTemplate(targetDir, siteName) {
65
- console.log("");
66
- console.log(` \u23F3 Creating ${siteName?.trim() || "your docs site"}...`);
67
- const response = await fetch(TARBALL_URL);
68
- if (!response.ok) {
69
- throw new Error(`Failed to download template: ${response.status} ${response.statusText}`);
70
- }
71
- if (!response.body) {
72
- throw new Error("Response body is empty");
73
- }
74
- const nodeStream = Readable.fromWeb(response.body);
75
- await pipelineAsync(
76
- nodeStream,
77
- extract({ cwd: targetDir, strip: 1, filter: shouldInclude })
78
- );
79
- }
80
-
81
14
  // src/docs-json.ts
82
15
  import { readFileSync, writeFileSync } from "fs";
83
16
  import { join } from "path";
@@ -189,7 +122,16 @@ async function scaffold(options) {
189
122
  }
190
123
  mkdirSync(targetDir, { recursive: true });
191
124
  const slug = slugify(projectName);
192
- await downloadTemplate(targetDir, projectName);
125
+ await downloadStarter(targetDir, projectName);
126
+ personalizeStarter(targetDir, {
127
+ projectName,
128
+ packageName: slug,
129
+ description,
130
+ brandPreset,
131
+ repoUrl,
132
+ enableAiChat,
133
+ i18nLocales
134
+ });
193
135
  resetTrackingConfig(targetDir);
194
136
  if (trackRepos?.length) {
195
137
  writeTrackingConfig(targetDir, trackRepos);
@@ -198,16 +140,6 @@ async function scaffold(options) {
198
140
  console.log(" To finish wiring it: `thally track setup` (pick a trigger) + `thally agent init`,");
199
141
  console.log(" then add your ANTHROPIC_API_KEY. See /guides/thally-track.");
200
142
  }
201
- writeStarterContent(targetDir, projectName, enableAiChat, repoUrl, i18nLocales);
202
- writeStarterReadme(targetDir, projectName);
203
- writeStarterAgentGuide(targetDir, projectName);
204
- updateSiteConfig(targetDir, projectName, description, brandPreset, repoUrl);
205
- patchApiReferenceGuard(targetDir);
206
- patchTopBarNavigation(targetDir);
207
- patchOpenApiFetch(targetDir);
208
- patchPackageJson(targetDir, slug);
209
- writeCloudflareRuntimeConfig(targetDir, slug);
210
- patchGitignore(targetDir);
211
143
  updateEnvExample(targetDir);
212
144
  if (doInstall) {
213
145
  installDeps(targetDir);
@@ -222,10 +154,6 @@ export {
222
154
  installDeps,
223
155
  logo,
224
156
  success,
225
- TEMPLATE_REPOSITORY,
226
- TEMPLATE_COMMIT_SHA,
227
- EXCLUDE_PATHS,
228
- shouldInclude,
229
157
  readDocsJson,
230
158
  writeDocsJson,
231
159
  scaffold
@@ -3,7 +3,7 @@ import {
3
3
  initGit,
4
4
  installDeps,
5
5
  scaffold
6
- } from "./chunk-JNDRCCJV.js";
6
+ } from "./chunk-2N74PW2Y.js";
7
7
 
8
8
  // src/migrate/index.ts
9
9
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ STABLE_SCAFFOLD_RELEASE
4
+ } from "./chunk-RLGB6QF3.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
+ };