create-thally-docs 0.10.88 → 0.10.90

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.
@@ -8,7 +8,7 @@ import {
8
8
  installDeps,
9
9
  runProjectCommand,
10
10
  scaffold
11
- } from "./chunk-YBVSKK53.js";
11
+ } from "./chunk-IX2FP4D5.js";
12
12
  import {
13
13
  pruneMissingSiteLinks
14
14
  } from "./chunk-UG3OXVLE.js";
@@ -9,7 +9,7 @@ import {
9
9
  } from "./chunk-J7255UQA.js";
10
10
  import {
11
11
  downloadStarter
12
- } from "./chunk-QCHFKSPD.js";
12
+ } from "./chunk-VHIX2IYK.js";
13
13
  import {
14
14
  terminal
15
15
  } from "./chunk-L2YCVQBR.js";
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ downloadStarter
4
+ } from "./chunk-VHIX2IYK.js";
5
+ import {
6
+ applyStarterRuntimeSyncPlan,
7
+ captureTargetPrecondition,
8
+ mergeStarterOwnershipContracts,
9
+ parseStarterReleaseManifest,
10
+ planStarterRuntimeSync,
11
+ readStarterReleaseManifest,
12
+ starterManifestSha256
13
+ } from "./chunk-LKI5X3H2.js";
14
+ import {
15
+ STABLE_SCAFFOLD_RELEASE,
16
+ SUPPORTED_SCAFFOLD_RELEASES
17
+ } from "./chunk-D7ANFHIL.js";
18
+
19
+ // src/starter-update.ts
20
+ import {
21
+ existsSync,
22
+ lstatSync,
23
+ mkdirSync,
24
+ mkdtempSync,
25
+ readFileSync,
26
+ rmSync
27
+ } from "fs";
28
+ import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
29
+ import { tmpdir } from "os";
30
+ import { join } from "path";
31
+
32
+ // src/starter-manual-review.ts
33
+ import { isDeepStrictEqual } from "util";
34
+ var STRUCTURED_MANUAL_REVIEW_PATHS = /* @__PURE__ */ new Set([
35
+ "package.json",
36
+ "package-lock.json"
37
+ ]);
38
+ var MISSING_JSON_VALUE = /* @__PURE__ */ Symbol("missing-json-value");
39
+ function hasSameBytes(left, right) {
40
+ if (!left || !right) return left === right;
41
+ return left.equals(right);
42
+ }
43
+ function isJsonObject(value) {
44
+ return value !== MISSING_JSON_VALUE && value !== null && typeof value === "object" && !Array.isArray(value);
45
+ }
46
+ function escapeJsonPointerSegment(segment) {
47
+ return segment.replaceAll("~", "~0").replaceAll("/", "~1");
48
+ }
49
+ function childJsonPointer(pointer, segment) {
50
+ return `${pointer}/${escapeJsonPointerSegment(segment)}`;
51
+ }
52
+ function sameJsonValue(left, right) {
53
+ if (left === MISSING_JSON_VALUE || right === MISSING_JSON_VALUE) {
54
+ return left === right;
55
+ }
56
+ return isDeepStrictEqual(left, right);
57
+ }
58
+ function mergeJsonValue(previous, downstream, target, pointer = "") {
59
+ if (sameJsonValue(downstream, target)) {
60
+ return { value: downstream, conflicts: [] };
61
+ }
62
+ if (sameJsonValue(previous, target)) {
63
+ return { value: downstream, conflicts: [] };
64
+ }
65
+ if (sameJsonValue(previous, downstream)) {
66
+ return { value: target, conflicts: [] };
67
+ }
68
+ if (isJsonObject(previous) && isJsonObject(downstream) && isJsonObject(target)) {
69
+ const keys = Array.from(
70
+ /* @__PURE__ */ new Set([
71
+ ...Object.keys(downstream),
72
+ ...Object.keys(target),
73
+ ...Object.keys(previous)
74
+ ])
75
+ );
76
+ const entries = [];
77
+ const conflicts = [];
78
+ for (const key of keys) {
79
+ const merged = mergeJsonValue(
80
+ Object.hasOwn(previous, key) ? previous[key] : MISSING_JSON_VALUE,
81
+ Object.hasOwn(downstream, key) ? downstream[key] : MISSING_JSON_VALUE,
82
+ Object.hasOwn(target, key) ? target[key] : MISSING_JSON_VALUE,
83
+ childJsonPointer(pointer, key)
84
+ );
85
+ conflicts.push(...merged.conflicts);
86
+ if (merged.value !== MISSING_JSON_VALUE) {
87
+ entries.push([key, merged.value]);
88
+ }
89
+ }
90
+ return { value: Object.fromEntries(entries), conflicts };
91
+ }
92
+ return { value: downstream, conflicts: [pointer || "/"] };
93
+ }
94
+ function mergeStructuredManualReviewFile(input) {
95
+ try {
96
+ const previous = JSON.parse(input.previous.toString("utf8"));
97
+ const downstream = JSON.parse(
98
+ input.downstream.toString("utf8")
99
+ );
100
+ const target = JSON.parse(input.target.toString("utf8"));
101
+ const merged = mergeJsonValue(previous, downstream, target);
102
+ if (merged.conflicts.length > 0 || merged.value === MISSING_JSON_VALUE) {
103
+ return { conflicts: merged.conflicts };
104
+ }
105
+ return {
106
+ content: Buffer.from(`${JSON.stringify(merged.value, null, 2)}
107
+ `),
108
+ conflicts: []
109
+ };
110
+ } catch {
111
+ return { conflicts: ["/"] };
112
+ }
113
+ }
114
+ function resolveManualReviewMutations(paths, downstreamFiles, previousReleaseFiles, targetReleaseFiles) {
115
+ const mutations = {
116
+ writeFiles: /* @__PURE__ */ new Map(),
117
+ deletePaths: [],
118
+ conflicts: []
119
+ };
120
+ for (const path of paths) {
121
+ const downstream = downstreamFiles.get(path);
122
+ const previous = previousReleaseFiles.get(path);
123
+ const target = targetReleaseFiles.get(path);
124
+ if (hasSameBytes(previous, target) || hasSameBytes(downstream, target)) {
125
+ continue;
126
+ }
127
+ if (hasSameBytes(downstream, previous)) {
128
+ if (target) mutations.writeFiles.set(path, target);
129
+ else mutations.deletePaths.push(path);
130
+ continue;
131
+ }
132
+ if (STRUCTURED_MANUAL_REVIEW_PATHS.has(path) && downstream && previous && target) {
133
+ const merged = mergeStructuredManualReviewFile({
134
+ previous,
135
+ downstream,
136
+ target
137
+ });
138
+ if (merged.content) {
139
+ if (!hasSameBytes(downstream, merged.content)) {
140
+ mutations.writeFiles.set(path, merged.content);
141
+ }
142
+ continue;
143
+ }
144
+ mutations.conflicts.push({ path, fields: merged.conflicts });
145
+ continue;
146
+ }
147
+ mutations.conflicts.push({ path, fields: [] });
148
+ }
149
+ return mutations;
150
+ }
151
+
152
+ // src/starter-update.ts
153
+ var EMPTY_PLAN = {
154
+ copyPaths: [],
155
+ deletePaths: [],
156
+ conflictPaths: [],
157
+ manualReviewPaths: [],
158
+ preservedPaths: [],
159
+ unchangedPaths: [],
160
+ targetPreconditions: {}
161
+ };
162
+ function readDownstreamManifest(targetDir, manifestPath) {
163
+ const path = join(targetDir, manifestPath);
164
+ if (!existsSync(path)) {
165
+ throw new Error(
166
+ `This project has no ${manifestPath}; update it from a versioned Thally starter first.`
167
+ );
168
+ }
169
+ const entry = lstatSync(path);
170
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 256 * 1024) {
171
+ throw new Error("The project starter manifest must be a bounded regular file.");
172
+ }
173
+ return readFileSync(path, "utf8");
174
+ }
175
+ function resolveStarterReleaseFromManifest(source, releases = SUPPORTED_SCAFFOLD_RELEASES) {
176
+ const hash = starterManifestSha256(source);
177
+ const matching = releases.filter(
178
+ (release) => release.source.manifestSha256 === hash
179
+ );
180
+ if (matching.length !== 1) {
181
+ throw new Error(
182
+ "The project starter manifest is edited, unknown, or ambiguously registered; refusing to update."
183
+ );
184
+ }
185
+ parseStarterReleaseManifest(source, matching[0]);
186
+ return matching[0];
187
+ }
188
+ function starterReleaseProviderError(cause) {
189
+ return Object.assign(new Error("The public starter release provider is unavailable.", { cause }), {
190
+ name: "StarterReleaseProviderError",
191
+ code: "STARTER_RELEASE_PROVIDER_UNAVAILABLE"
192
+ });
193
+ }
194
+ async function fetchStarterReleaseHint(runtimeCommitSha) {
195
+ if (!/^[a-f0-9]{40}$/.test(runtimeCommitSha)) return void 0;
196
+ let response;
197
+ try {
198
+ response = await fetch(
199
+ `https://raw.githubusercontent.com/thallylabs/thally/${runtimeCommitSha}/packages/create-thally-docs/src/stable-scaffold-release.json`,
200
+ { signal: AbortSignal.timeout(15e3), redirect: "error" }
201
+ );
202
+ } catch (error) {
203
+ throw starterReleaseProviderError(error);
204
+ }
205
+ if (response.status === 404) return void 0;
206
+ if (!response.ok || !response.body) throw starterReleaseProviderError();
207
+ const reader = response.body.getReader();
208
+ const chunks = [];
209
+ let size = 0;
210
+ try {
211
+ while (true) {
212
+ let chunk;
213
+ try {
214
+ chunk = await reader.read();
215
+ } catch (error) {
216
+ throw starterReleaseProviderError(error);
217
+ }
218
+ if (chunk.done) break;
219
+ size += chunk.value.byteLength;
220
+ if (size > 256 * 1024) return void 0;
221
+ chunks.push(chunk.value);
222
+ }
223
+ try {
224
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
225
+ } catch {
226
+ return void 0;
227
+ }
228
+ } finally {
229
+ await reader.cancel().catch(() => void 0);
230
+ }
231
+ }
232
+ function comparableManifest(source) {
233
+ const manifest = JSON.parse(source);
234
+ const runtime = manifest.runtime;
235
+ if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return void 0;
236
+ const runtimeContract = { ...runtime };
237
+ delete runtimeContract.commitSha;
238
+ delete runtimeContract.treeSha;
239
+ return { ...manifest, runtime: runtimeContract };
240
+ }
241
+ function assertRecoveryPackages(targetDir, baselineDir, packages) {
242
+ const installed = JSON.parse(readFileSync(join(targetDir, "package.json"), "utf8"));
243
+ const declarations = { ...installed.dependencies, ...installed.devDependencies };
244
+ for (const [name, version] of Object.entries(packages)) {
245
+ if (declarations[name] !== version) throw new Error("Installed starter packages do not match the recovery baseline.");
246
+ }
247
+ if (existsSync(join(baselineDir, "package-lock.json"))) {
248
+ const canonical = JSON.parse(readFileSync(join(baselineDir, "package-lock.json"), "utf8"));
249
+ const current = JSON.parse(readFileSync(join(targetDir, "package-lock.json"), "utf8"));
250
+ for (const name of Object.keys(packages)) {
251
+ const path = `node_modules/${name}`;
252
+ const expected = canonical.packages?.[path];
253
+ if (!expected?.version || !isDeepStrictEqual2(current.packages?.[path], expected)) {
254
+ throw new Error("Installed starter package lock does not match the recovery baseline.");
255
+ }
256
+ }
257
+ }
258
+ }
259
+ async function resolveStarterUpdateBase(options) {
260
+ const releases = options.releases ?? SUPPORTED_SCAFFOLD_RELEASES;
261
+ const manifestPath = STABLE_SCAFFOLD_RELEASE.source.manifestPath;
262
+ const source = readDownstreamManifest(options.targetDir, manifestPath);
263
+ const manifestSha256 = starterManifestSha256(source);
264
+ const exact = releases.filter((release) => release.source.manifestSha256 === manifestSha256);
265
+ if (exact.length) return { release: resolveStarterReleaseFromManifest(source, releases), recoveredProvenance: false, manifestSha256 };
266
+ if (Buffer.byteLength(source, "utf8") > 256 * 1024) throw new Error("The project starter manifest is too large.");
267
+ let candidate;
268
+ try {
269
+ candidate = JSON.parse(source);
270
+ } catch {
271
+ throw new Error("The project starter manifest is invalid JSON.");
272
+ }
273
+ const runtime = candidate?.runtime;
274
+ if (!runtime || typeof runtime.commitSha !== "string" || !/^[a-f0-9]{40}$/.test(runtime.commitSha) || typeof runtime.treeSha !== "string" || !/^[a-f0-9]{40}$/.test(runtime.treeSha)) {
275
+ throw new Error("The project starter manifest has no verifiable recovery baseline.");
276
+ }
277
+ let candidates = releases.filter((release) => release.runtime.commitSha === runtime.commitSha);
278
+ if (!candidates.length) {
279
+ const hint = await (options.resolveReleaseHint ?? fetchStarterReleaseHint)(runtime.commitSha);
280
+ const record = hint;
281
+ candidates = releases.filter((release) => record?.id === release.id && record.source?.commitSha === release.source.commitSha);
282
+ }
283
+ const download = options.download ?? (async (directory, release) => {
284
+ await downloadStarter(directory, void 0, release, { announce: false });
285
+ });
286
+ const workspace = mkdtempSync(join(tmpdir(), "thally-starter-base-"));
287
+ try {
288
+ for (const [index, release] of candidates.entries()) {
289
+ const baselineDir = join(workspace, String(index));
290
+ mkdirSync(baselineDir);
291
+ await download(baselineDir, release);
292
+ const baseline = readStarterReleaseManifest(baselineDir, release);
293
+ const canonicalSource = readFileSync(join(baselineDir, manifestPath), "utf8");
294
+ if (!isDeepStrictEqual2(comparableManifest(source), comparableManifest(canonicalSource))) continue;
295
+ const ownership = mergeStarterOwnershipContracts(baseline.ownership, baseline.ownership);
296
+ const proof = planStarterRuntimeSync(baselineDir, baselineDir, options.targetDir, ownership);
297
+ if (proof.copyPaths.length || proof.deletePaths.length || proof.preservedPaths.length || proof.conflictPaths.length) continue;
298
+ assertRecoveryPackages(options.targetDir, baselineDir, baseline.packages);
299
+ return { release, recoveredProvenance: true, manifestSha256 };
300
+ }
301
+ throw new Error("The project starter manifest is edited, unknown, or has no verified published framework baseline; refusing to update.");
302
+ } finally {
303
+ rmSync(workspace, { recursive: true, force: true });
304
+ }
305
+ }
306
+ function assertForwardRelease(previous, target, releases) {
307
+ if (previous.source.repository !== target.source.repository || previous.source.manifestPath !== target.source.manifestPath) {
308
+ throw new Error("Starter updates cannot cross repository or manifest contracts.");
309
+ }
310
+ if (previous.source.commitSha === target.source.commitSha) return;
311
+ if (previous.starterVersion > target.starterVersion) {
312
+ throw new Error("The installed CLI does not contain a newer starter release.");
313
+ }
314
+ if (previous.starterVersion === target.starterVersion) {
315
+ const targetIndex = releases.findIndex(
316
+ (release) => release.source.commitSha === target.source.commitSha
317
+ );
318
+ const previousIndex = releases.findIndex(
319
+ (release) => release.source.commitSha === previous.source.commitSha
320
+ );
321
+ if (targetIndex === -1 || previousIndex === -1 || previousIndex <= targetIndex) {
322
+ throw new Error("The installed CLI does not contain a newer starter release.");
323
+ }
324
+ }
325
+ }
326
+ async function updateStarterProject(options) {
327
+ const targetRelease = options.targetRelease ?? STABLE_SCAFFOLD_RELEASE;
328
+ const releases = options.releases ?? SUPPORTED_SCAFFOLD_RELEASES;
329
+ if (!releases.some((release) => isDeepStrictEqual2(release, targetRelease))) {
330
+ throw new Error("The target starter release is not in the trusted release catalog.");
331
+ }
332
+ const manifestPath = targetRelease.source.manifestPath;
333
+ const downstreamManifest = readDownstreamManifest(options.targetDir, manifestPath);
334
+ const base = await resolveStarterUpdateBase({ ...options, releases });
335
+ const previousRelease = base.release;
336
+ if (base.manifestSha256 !== starterManifestSha256(downstreamManifest) || readDownstreamManifest(options.targetDir, manifestPath) !== downstreamManifest) {
337
+ throw new Error("The project starter manifest changed during update planning.");
338
+ }
339
+ assertForwardRelease(previousRelease, targetRelease, releases);
340
+ if (!base.recoveredProvenance && previousRelease.source.commitSha === targetRelease.source.commitSha) {
341
+ return {
342
+ previousRelease,
343
+ targetRelease,
344
+ plan: EMPTY_PLAN,
345
+ isUpToDate: true,
346
+ applied: false
347
+ };
348
+ }
349
+ if (options.apply && !options.confirmed) {
350
+ throw new Error("Pass explicit confirmation before applying a starter update.");
351
+ }
352
+ const workspace = mkdtempSync(join(tmpdir(), "thally-starter-update-"));
353
+ const oldStarterDir = join(workspace, "old");
354
+ const newStarterDir = join(workspace, "new");
355
+ mkdirSync(oldStarterDir);
356
+ mkdirSync(newStarterDir);
357
+ const download = options.download ?? (async (targetDir, release) => {
358
+ await downloadStarter(targetDir, void 0, release, { announce: false });
359
+ });
360
+ try {
361
+ await download(oldStarterDir, previousRelease);
362
+ await download(newStarterDir, targetRelease);
363
+ const oldManifest = readStarterReleaseManifest(oldStarterDir, previousRelease);
364
+ const newManifest = readStarterReleaseManifest(newStarterDir, targetRelease);
365
+ const ownership = mergeStarterOwnershipContracts(
366
+ oldManifest.ownership,
367
+ newManifest.ownership
368
+ );
369
+ if (base.recoveredProvenance) {
370
+ const proof = planStarterRuntimeSync(
371
+ oldStarterDir,
372
+ oldStarterDir,
373
+ options.targetDir,
374
+ mergeStarterOwnershipContracts(oldManifest.ownership, oldManifest.ownership)
375
+ );
376
+ if (proof.copyPaths.length || proof.deletePaths.length || proof.preservedPaths.length || proof.conflictPaths.length) {
377
+ throw new Error("The recovery framework changed during update planning.");
378
+ }
379
+ assertRecoveryPackages(options.targetDir, oldStarterDir, oldManifest.packages);
380
+ }
381
+ if (readDownstreamManifest(options.targetDir, manifestPath) !== downstreamManifest) {
382
+ throw new Error("The project starter manifest changed during update planning.");
383
+ }
384
+ const plan = planStarterRuntimeSync(
385
+ oldStarterDir,
386
+ newStarterDir,
387
+ options.targetDir,
388
+ ownership
389
+ );
390
+ const allManualPreconditions = Object.fromEntries(plan.manualReviewPaths.map((path) => [
391
+ path,
392
+ captureTargetPrecondition(join(options.targetDir, path))
393
+ ]));
394
+ const readManualFiles = (root) => new Map(
395
+ plan.manualReviewPaths.filter((path) => existsSync(join(root, path))).map((path) => {
396
+ const entry = lstatSync(join(root, path));
397
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("Manual starter updates require regular files.");
398
+ return [path, readFileSync(join(root, path))];
399
+ })
400
+ );
401
+ const manual = resolveManualReviewMutations(
402
+ plan.manualReviewPaths,
403
+ readManualFiles(options.targetDir),
404
+ readManualFiles(oldStarterDir),
405
+ readManualFiles(newStarterDir)
406
+ );
407
+ const resolvedManualPaths = [...manual.writeFiles.keys(), ...manual.deletePaths].sort();
408
+ const manualPreconditions = Object.fromEntries(resolvedManualPaths.map((path) => [
409
+ path,
410
+ allManualPreconditions[path]
411
+ ]));
412
+ plan.conflictPaths = [.../* @__PURE__ */ new Set([...plan.conflictPaths, ...manual.conflicts.map((conflict) => conflict.path)])].sort();
413
+ plan.manualReviewPaths = manual.conflicts.map((conflict) => conflict.path);
414
+ if (!options.apply) {
415
+ return {
416
+ previousRelease,
417
+ targetRelease,
418
+ plan,
419
+ isUpToDate: false,
420
+ applied: false,
421
+ recoveredProvenance: base.recoveredProvenance,
422
+ manualConflicts: manual.conflicts,
423
+ resolvedManualPaths,
424
+ deletedManualPaths: manual.deletePaths
425
+ };
426
+ }
427
+ if (plan.conflictPaths.length > 0) {
428
+ throw new Error("Resolve starter synchronization conflicts before applying.");
429
+ }
430
+ if (readDownstreamManifest(options.targetDir, manifestPath) !== downstreamManifest) {
431
+ throw new Error("The project starter manifest changed during update planning.");
432
+ }
433
+ applyStarterRuntimeSyncPlan(
434
+ newStarterDir,
435
+ options.targetDir,
436
+ plan,
437
+ ownership,
438
+ {
439
+ confirmed: true,
440
+ manualMutations: { ...manual, targetPreconditions: manualPreconditions },
441
+ provenance: {
442
+ sourcePath: join(newStarterDir, manifestPath),
443
+ targetPath: join(options.targetDir, manifestPath),
444
+ expectedSha256: base.manifestSha256
445
+ }
446
+ }
447
+ );
448
+ return {
449
+ previousRelease,
450
+ targetRelease,
451
+ plan,
452
+ isUpToDate: false,
453
+ applied: true,
454
+ recoveredProvenance: base.recoveredProvenance,
455
+ manualConflicts: manual.conflicts,
456
+ resolvedManualPaths,
457
+ deletedManualPaths: manual.deletePaths
458
+ };
459
+ } finally {
460
+ rmSync(workspace, { recursive: true, force: true });
461
+ }
462
+ }
463
+
464
+ export {
465
+ resolveManualReviewMutations,
466
+ resolveStarterReleaseFromManifest,
467
+ starterReleaseProviderError,
468
+ fetchStarterReleaseHint,
469
+ resolveStarterUpdateBase,
470
+ updateStarterProject
471
+ };
@@ -14,6 +14,7 @@ import {
14
14
  readFileSync,
15
15
  readdirSync,
16
16
  renameSync,
17
+ writeFileSync,
17
18
  rmSync
18
19
  } from "fs";
19
20
  import { dirname, join, relative, resolve, sep } from "path";
@@ -212,6 +213,7 @@ function collectFiles(root, contract, desiredOwnership) {
212
213
  const rules = desiredOwnership === "syncable" ? contract.frameworkSyncEligible : contract.manualReview;
213
214
  const visit = (absolutePath) => {
214
215
  const resolvedPath = resolve(absolutePath);
216
+ if (resolvedPath !== resolve(root)) assertSafeTargetParents(root, resolvedPath);
215
217
  if (visited.has(resolvedPath)) return;
216
218
  visited.add(resolvedPath);
217
219
  const repositoryPath = filesystemPathToRepositoryPath(root, resolvedPath);
@@ -362,15 +364,20 @@ function applyStarterRuntimeSyncPlan(newStarterDir, targetDir, plan, ownership,
362
364
  if (plan.conflictPaths.length > 0) {
363
365
  throw new Error("Resolve starter synchronization conflicts before applying.");
364
366
  }
365
- const uniqueMutations = /* @__PURE__ */ new Set([...plan.copyPaths, ...plan.deletePaths]);
366
- if (uniqueMutations.size !== plan.copyPaths.length + plan.deletePaths.length) {
367
+ const manual = options.manualMutations;
368
+ const copyPaths = [...plan.copyPaths, ...manual?.writeFiles.keys() ?? []];
369
+ const deletePaths = [...plan.deletePaths, ...manual?.deletePaths ?? []];
370
+ const preconditions = { ...plan.targetPreconditions, ...manual?.targetPreconditions };
371
+ const uniqueMutations = /* @__PURE__ */ new Set([...copyPaths, ...deletePaths]);
372
+ if (uniqueMutations.size !== copyPaths.length + deletePaths.length) {
367
373
  throw new Error("Starter synchronization plan contains conflicting mutations.");
368
374
  }
369
375
  for (const path of uniqueMutations) {
370
- if (classifyStarterPath(path, contract) !== "syncable") {
376
+ const isManualMutation = manual?.writeFiles.has(path) || manual?.deletePaths.includes(path);
377
+ if (classifyStarterPath(path, contract) !== (isManualMutation ? "manual" : "syncable")) {
371
378
  throw new Error(`Starter synchronization refused owner path ${path}.`);
372
379
  }
373
- if (!plan.targetPreconditions[path]) {
380
+ if (!preconditions[path]) {
374
381
  throw new Error(`Starter synchronization plan lacks a target precondition for ${path}.`);
375
382
  }
376
383
  }
@@ -428,7 +435,11 @@ function applyStarterRuntimeSyncPlan(newStarterDir, targetDir, plan, ownership,
428
435
  mutationIndex += 1;
429
436
  };
430
437
  try {
431
- for (const path of plan.copyPaths) {
438
+ for (const path of copyPaths) {
439
+ if (manual?.writeFiles.has(path)) {
440
+ writeFileSync(stagePath(path), manual.writeFiles.get(path));
441
+ continue;
442
+ }
432
443
  const sourcePath = ensureContained(newStarterDir, path);
433
444
  if (!existsSync(sourcePath) || !lstatSync(sourcePath).isFile()) {
434
445
  throw new Error(`Starter synchronization source is missing ${path}.`);
@@ -446,7 +457,7 @@ function applyStarterRuntimeSyncPlan(newStarterDir, targetDir, plan, ownership,
446
457
  for (const path of uniqueMutations) {
447
458
  const targetPath = ensureContained(targetDir, path);
448
459
  assertSafeTargetParents(targetDir, targetPath);
449
- if (!matchesTargetPrecondition(targetPath, plan.targetPreconditions[path])) {
460
+ if (!matchesTargetPrecondition(targetPath, preconditions[path])) {
450
461
  throw new Error(`Starter synchronization target changed after planning: ${path}.`);
451
462
  }
452
463
  }
@@ -456,18 +467,18 @@ function applyStarterRuntimeSyncPlan(newStarterDir, targetDir, plan, ownership,
456
467
  throw new Error("The project starter manifest changed during update planning.");
457
468
  }
458
469
  }
459
- for (const path of plan.deletePaths) {
470
+ for (const path of deletePaths) {
460
471
  mutate(
461
472
  path,
462
473
  ensureContained(targetDir, path),
463
- plan.targetPreconditions[path]
474
+ preconditions[path]
464
475
  );
465
476
  }
466
- for (const path of plan.copyPaths) {
477
+ for (const path of copyPaths) {
467
478
  mutate(
468
479
  path,
469
480
  ensureContained(targetDir, path),
470
- plan.targetPreconditions[path],
481
+ preconditions[path],
471
482
  ensureContained(stagedDir, path)
472
483
  );
473
484
  }
@@ -515,6 +526,7 @@ export {
515
526
  parseStarterReleaseManifest,
516
527
  readStarterReleaseManifest,
517
528
  classifyStarterPath,
529
+ captureTargetPrecondition,
518
530
  planStarterRuntimeSync,
519
531
  applyStarterRuntimeSyncPlan
520
532
  };
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ terminal
4
+ } from "./chunk-L2YCVQBR.js";
2
5
  import {
3
6
  readStarterReleaseManifest
4
- } from "./chunk-MFJ6H3MQ.js";
7
+ } from "./chunk-LKI5X3H2.js";
5
8
  import {
6
9
  STABLE_SCAFFOLD_RELEASE
7
10
  } from "./chunk-D7ANFHIL.js";
8
- import {
9
- terminal
10
- } from "./chunk-L2YCVQBR.js";
11
11
 
12
12
  // src/download.ts
13
13
  import { Readable, Transform, pipeline } from "stream";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  migrateDocs
4
- } from "./chunk-JZ75H4V3.js";
4
+ } from "./chunk-HRCNLXFC.js";
5
5
  import {
6
6
  parseFrontmatter,
7
7
  runCheck
@@ -13,17 +13,17 @@ import {
13
13
  scaffold,
14
14
  slugify,
15
15
  success
16
- } from "./chunk-YBVSKK53.js";
16
+ } from "./chunk-IX2FP4D5.js";
17
17
  import "./chunk-UG3OXVLE.js";
18
18
  import "./chunk-J7255UQA.js";
19
- import "./chunk-QCHFKSPD.js";
20
- import "./chunk-MFJ6H3MQ.js";
21
- import "./chunk-D7ANFHIL.js";
19
+ import "./chunk-VHIX2IYK.js";
22
20
  import {
23
21
  acquireTerminalAccent,
24
22
  formatHelp,
25
23
  terminal
26
24
  } from "./chunk-L2YCVQBR.js";
25
+ import "./chunk-LKI5X3H2.js";
26
+ import "./chunk-D7ANFHIL.js";
27
27
 
28
28
  // src/index.ts
29
29
  import { existsSync as existsSync2, readdirSync } from "fs";
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  migrateDocs
4
- } from "../chunk-JZ75H4V3.js";
4
+ } from "../chunk-HRCNLXFC.js";
5
5
  import "../chunk-KDLP27DS.js";
6
- import "../chunk-YBVSKK53.js";
6
+ import "../chunk-IX2FP4D5.js";
7
7
  import "../chunk-UG3OXVLE.js";
8
8
  import "../chunk-J7255UQA.js";
9
- import "../chunk-QCHFKSPD.js";
10
- import "../chunk-MFJ6H3MQ.js";
11
- import "../chunk-D7ANFHIL.js";
9
+ import "../chunk-VHIX2IYK.js";
12
10
  import "../chunk-L2YCVQBR.js";
11
+ import "../chunk-LKI5X3H2.js";
12
+ import "../chunk-D7ANFHIL.js";
13
13
  export {
14
14
  migrateDocs
15
15
  };
package/dist/scaffold.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  scaffold
4
- } from "./chunk-YBVSKK53.js";
4
+ } from "./chunk-IX2FP4D5.js";
5
5
  import "./chunk-UG3OXVLE.js";
6
6
  import "./chunk-J7255UQA.js";
7
7
  import {
@@ -9,12 +9,12 @@ import {
9
9
  STARTER_COMMIT_SHA,
10
10
  STARTER_REPOSITORY,
11
11
  validateStarterArchiveEntry
12
- } from "./chunk-QCHFKSPD.js";
13
- import "./chunk-MFJ6H3MQ.js";
12
+ } from "./chunk-VHIX2IYK.js";
13
+ import "./chunk-L2YCVQBR.js";
14
+ import "./chunk-LKI5X3H2.js";
14
15
  import {
15
16
  STABLE_SCAFFOLD_RELEASE
16
17
  } from "./chunk-D7ANFHIL.js";
17
- import "./chunk-L2YCVQBR.js";
18
18
  export {
19
19
  STABLE_SCAFFOLD_RELEASE,
20
20
  STARTER_ARCHIVE_ROOT,
@@ -5,8 +5,8 @@ import { ScaffoldOwnershipContract, ScaffoldRelease, StarterReleaseManifest } fr
5
5
  *
6
6
  * The ownership contract comes from the pinned starter archive's
7
7
  * `starter-release.json`. Automatic updates touch only framework-eligible files
8
- * that still match the recorded old starter; owner and manual-review paths are
9
- * never included in an apply plan.
8
+ * that still match the recorded old starter. Owner paths are never mutated;
9
+ * manual-review files join the transaction only after a separate safe merge.
10
10
  */
11
11
 
12
12
  type StarterPathOwnership = 'protected' | 'manual' | 'syncable' | 'unmanaged';
@@ -34,6 +34,12 @@ interface ApplyStarterRuntimeSyncOptions {
34
34
  /** Optional immutable provenance file, atomically replaced after mutations. */
35
35
  provenance?: StarterApplyProvenance;
36
36
  /** Integration hook; an exception aborts and rolls back the transaction. */
37
+ /** Owner-reviewed paths explicitly resolved by the shared three-way merge. */
38
+ manualMutations?: {
39
+ writeFiles: ReadonlyMap<string, Buffer>;
40
+ deletePaths: readonly string[];
41
+ targetPreconditions: Record<string, StarterTargetPrecondition>;
42
+ };
37
43
  onMutationApplied?: (path: string, index: number) => void;
38
44
  }
39
45
  /** Parse and validate the ownership section in `starter-release.json`. */
@@ -57,6 +63,8 @@ declare function parseStarterReleaseManifest(source: string, release?: ScaffoldR
57
63
  declare function readStarterReleaseManifest(starterDir: string, release?: ScaffoldRelease): StarterReleaseManifest;
58
64
  /** Protected > manual-review > syncable > unmanaged. */
59
65
  declare function classifyStarterPath(path: string, contract: ScaffoldOwnershipContract): StarterPathOwnership;
66
+ /** Capture exact file identity before planning a filesystem mutation. */
67
+ declare function captureTargetPrecondition(path: string): StarterTargetPrecondition;
60
68
  /**
61
69
  * Build a safe three-way update plan from the recorded old starter, the new
62
70
  * starter, and the customer's current tree.
@@ -65,4 +73,4 @@ declare function planStarterRuntimeSync(oldStarterDir: string, newStarterDir: st
65
73
  /** Apply an explicitly confirmed, conflict-free three-way plan. */
66
74
  declare function applyStarterRuntimeSyncPlan(newStarterDir: string, targetDir: string, plan: StarterRuntimeSyncPlan, ownership: ScaffoldOwnershipContract, options: ApplyStarterRuntimeSyncOptions): void;
67
75
 
68
- export { type ApplyStarterRuntimeSyncOptions, type StarterApplyProvenance, type StarterPathOwnership, type StarterRuntimeSyncPlan, type StarterTargetPrecondition, applyStarterRuntimeSyncPlan, classifyStarterPath, mergeStarterOwnershipContracts, parseStarterOwnershipContract, parseStarterReleaseManifest, planStarterRuntimeSync, readStarterReleaseManifest, starterManifestSha256 };
76
+ export { type ApplyStarterRuntimeSyncOptions, type StarterApplyProvenance, type StarterPathOwnership, type StarterRuntimeSyncPlan, type StarterTargetPrecondition, applyStarterRuntimeSyncPlan, captureTargetPrecondition, classifyStarterPath, mergeStarterOwnershipContracts, parseStarterOwnershipContract, parseStarterReleaseManifest, planStarterRuntimeSync, readStarterReleaseManifest, starterManifestSha256 };
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  applyStarterRuntimeSyncPlan,
4
+ captureTargetPrecondition,
4
5
  classifyStarterPath,
5
6
  mergeStarterOwnershipContracts,
6
7
  parseStarterOwnershipContract,
@@ -8,10 +9,11 @@ import {
8
9
  planStarterRuntimeSync,
9
10
  readStarterReleaseManifest,
10
11
  starterManifestSha256
11
- } from "./chunk-MFJ6H3MQ.js";
12
+ } from "./chunk-LKI5X3H2.js";
12
13
  import "./chunk-D7ANFHIL.js";
13
14
  export {
14
15
  applyStarterRuntimeSyncPlan,
16
+ captureTargetPrecondition,
15
17
  classifyStarterPath,
16
18
  mergeStarterOwnershipContracts,
17
19
  parseStarterOwnershipContract,
@@ -1,6 +1,28 @@
1
1
  import { ScaffoldRelease } from './release.js';
2
2
  import { StarterRuntimeSyncPlan } from './starter-sync.js';
3
3
 
4
+ /** File-level and JSON-pointer conflict details without customer file contents. */
5
+ interface StarterManualConflict {
6
+ path: string;
7
+ fields: string[];
8
+ }
9
+ /** Complete resolved writes and deletions, or conflicts requiring owner review. */
10
+ interface ManualReviewMutations {
11
+ writeFiles: Map<string, Buffer>;
12
+ deletePaths: Array<string>;
13
+ conflicts: Array<StarterManualConflict>;
14
+ }
15
+ /**
16
+ * Resolve manual-review files without silently overwriting downstream work.
17
+ *
18
+ * Package manifests and lockfiles sit outside the hidden runtime ownership
19
+ * surface. Their object keys have independent ownership, so non-overlapping
20
+ * JSON edits merge structurally: a customer package name survives while a
21
+ * framework dependency advances. Same-field edits and every unstructured
22
+ * manual-review file remain fail-closed.
23
+ */
24
+ declare function resolveManualReviewMutations(paths: readonly string[], downstreamFiles: ReadonlyMap<string, Buffer>, previousReleaseFiles: ReadonlyMap<string, Buffer>, targetReleaseFiles: ReadonlyMap<string, Buffer>): ManualReviewMutations;
25
+
4
26
  /**
5
27
  * User-reachable orchestration for immutable starter release updates.
6
28
  *
@@ -22,6 +44,7 @@ interface UpdateStarterProjectOptions {
22
44
  targetRelease?: ScaffoldRelease;
23
45
  releases?: readonly ScaffoldRelease[];
24
46
  download?: StarterUpdateDownload;
47
+ resolveReleaseHint?: StarterReleaseHintResolver;
25
48
  }
26
49
  interface StarterUpdateResult {
27
50
  previousRelease: ScaffoldRelease;
@@ -29,9 +52,44 @@ interface StarterUpdateResult {
29
52
  plan: StarterRuntimeSyncPlan;
30
53
  isUpToDate: boolean;
31
54
  applied: boolean;
55
+ /** A verified published baseline replaced noncanonical manifest provenance. */
56
+ manualConflicts?: StarterManualConflict[];
57
+ resolvedManualPaths?: string[];
58
+ deletedManualPaths?: string[];
59
+ recoveredProvenance?: boolean;
32
60
  }
33
61
  /** Resolve exact downstream provenance without consulting a moving branch. */
34
62
  declare function resolveStarterReleaseFromManifest(source: string, releases?: readonly ScaffoldRelease[]): ScaffoldRelease;
63
+ /** An untrusted promotion record can suggest only an already trusted release. */
64
+ interface StarterReleaseHintResolver {
65
+ (runtimeCommitSha: string): Promise<unknown>;
66
+ }
67
+ /** Recognizable transient failure; callers must not cache it as unsupported. */
68
+ declare function starterReleaseProviderError(cause?: unknown): Error & {
69
+ code: string;
70
+ };
71
+ /** Read a bounded lookup hint from the fixed public repository at an exact SHA. */
72
+ declare function fetchStarterReleaseHint(runtimeCommitSha: string): Promise<unknown>;
73
+ /** Trusted catalog and bounded download seams shared by CLI and managed callers. */
74
+ interface ResolveStarterUpdateBaseOptions {
75
+ targetDir: string;
76
+ releases?: readonly ScaffoldRelease[];
77
+ download?: StarterUpdateDownload;
78
+ resolveReleaseHint?: StarterReleaseHintResolver;
79
+ }
80
+ /** Published baseline identity plus evidence needed to guard provenance writes. */
81
+ interface StarterUpdateBase {
82
+ release: ScaffoldRelease;
83
+ recoveredProvenance: boolean;
84
+ /** The actual initial bytes, including noncanonical provenance, guard apply. */
85
+ manifestSha256: string;
86
+ }
87
+ /**
88
+ * Resolve a published three-way base. Unknown provenance is accepted only when
89
+ * its contract and complete framework inventory equal a trusted published tree.
90
+ * Neither arbitrary manifest ownership nor a fetched hint authorizes writes.
91
+ */
92
+ declare function resolveStarterUpdateBase(options: ResolveStarterUpdateBaseOptions): Promise<StarterUpdateBase>;
35
93
  /**
36
94
  * Plan or explicitly apply an immutable three-way starter update.
37
95
  *
@@ -40,4 +98,4 @@ declare function resolveStarterReleaseFromManifest(source: string, releases?: re
40
98
  */
41
99
  declare function updateStarterProject(options: UpdateStarterProjectOptions): Promise<StarterUpdateResult>;
42
100
 
43
- export { type StarterUpdateDownload, type StarterUpdateResult, type UpdateStarterProjectOptions, resolveStarterReleaseFromManifest, updateStarterProject };
101
+ export { type ManualReviewMutations, type ResolveStarterUpdateBaseOptions, type StarterManualConflict, type StarterReleaseHintResolver, type StarterUpdateBase, type StarterUpdateDownload, type StarterUpdateResult, type UpdateStarterProjectOptions, fetchStarterReleaseHint, resolveManualReviewMutations, resolveStarterReleaseFromManifest, resolveStarterUpdateBase, starterReleaseProviderError, updateStarterProject };
@@ -1,168 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- downloadStarter
4
- } from "./chunk-QCHFKSPD.js";
5
- import {
6
- applyStarterRuntimeSyncPlan,
7
- mergeStarterOwnershipContracts,
8
- parseStarterReleaseManifest,
9
- planStarterRuntimeSync,
10
- readStarterReleaseManifest,
11
- starterManifestSha256
12
- } from "./chunk-MFJ6H3MQ.js";
13
- import {
14
- STABLE_SCAFFOLD_RELEASE,
15
- SUPPORTED_SCAFFOLD_RELEASES
16
- } from "./chunk-D7ANFHIL.js";
3
+ fetchStarterReleaseHint,
4
+ resolveManualReviewMutations,
5
+ resolveStarterReleaseFromManifest,
6
+ resolveStarterUpdateBase,
7
+ starterReleaseProviderError,
8
+ updateStarterProject
9
+ } from "./chunk-J5V4UJXD.js";
10
+ import "./chunk-VHIX2IYK.js";
17
11
  import "./chunk-L2YCVQBR.js";
18
-
19
- // src/starter-update.ts
20
- import {
21
- existsSync,
22
- mkdirSync,
23
- mkdtempSync,
24
- readFileSync,
25
- rmSync
26
- } from "fs";
27
- import { tmpdir } from "os";
28
- import { join } from "path";
29
- var EMPTY_PLAN = {
30
- copyPaths: [],
31
- deletePaths: [],
32
- conflictPaths: [],
33
- manualReviewPaths: [],
34
- preservedPaths: [],
35
- unchangedPaths: [],
36
- targetPreconditions: {}
37
- };
38
- function readDownstreamManifest(targetDir, manifestPath) {
39
- const path = join(targetDir, manifestPath);
40
- if (!existsSync(path)) {
41
- throw new Error(
42
- `This project has no ${manifestPath}; update it from a versioned Thally starter first.`
43
- );
44
- }
45
- return readFileSync(path, "utf8");
46
- }
47
- function resolveStarterReleaseFromManifest(source, releases = SUPPORTED_SCAFFOLD_RELEASES) {
48
- const hash = starterManifestSha256(source);
49
- const matching = releases.filter(
50
- (release) => release.source.manifestSha256 === hash
51
- );
52
- if (matching.length !== 1) {
53
- throw new Error(
54
- "The project starter manifest is edited, unknown, or ambiguously registered; refusing to update."
55
- );
56
- }
57
- parseStarterReleaseManifest(source, matching[0]);
58
- return matching[0];
59
- }
60
- function assertForwardRelease(previous, target, releases) {
61
- if (previous.source.repository !== target.source.repository || previous.source.manifestPath !== target.source.manifestPath) {
62
- throw new Error("Starter updates cannot cross repository or manifest contracts.");
63
- }
64
- if (previous.source.commitSha === target.source.commitSha) return;
65
- if (previous.starterVersion > target.starterVersion) {
66
- throw new Error("The installed CLI does not contain a newer starter release.");
67
- }
68
- if (previous.starterVersion === target.starterVersion) {
69
- const targetIndex = releases.findIndex(
70
- (release) => release.source.commitSha === target.source.commitSha
71
- );
72
- const previousIndex = releases.findIndex(
73
- (release) => release.source.commitSha === previous.source.commitSha
74
- );
75
- if (targetIndex === -1 || previousIndex === -1 || previousIndex <= targetIndex) {
76
- throw new Error("The installed CLI does not contain a newer starter release.");
77
- }
78
- }
79
- }
80
- async function updateStarterProject(options) {
81
- const targetRelease = options.targetRelease ?? STABLE_SCAFFOLD_RELEASE;
82
- const releases = options.releases ?? SUPPORTED_SCAFFOLD_RELEASES;
83
- const manifestPath = targetRelease.source.manifestPath;
84
- const downstreamManifest = readDownstreamManifest(options.targetDir, manifestPath);
85
- const previousRelease = resolveStarterReleaseFromManifest(
86
- downstreamManifest,
87
- releases
88
- );
89
- assertForwardRelease(previousRelease, targetRelease, releases);
90
- if (previousRelease.source.commitSha === targetRelease.source.commitSha) {
91
- return {
92
- previousRelease,
93
- targetRelease,
94
- plan: EMPTY_PLAN,
95
- isUpToDate: true,
96
- applied: false
97
- };
98
- }
99
- if (options.apply && !options.confirmed) {
100
- throw new Error("Pass explicit confirmation before applying a starter update.");
101
- }
102
- const workspace = mkdtempSync(join(tmpdir(), "thally-starter-update-"));
103
- const oldStarterDir = join(workspace, "old");
104
- const newStarterDir = join(workspace, "new");
105
- mkdirSync(oldStarterDir);
106
- mkdirSync(newStarterDir);
107
- const download = options.download ?? (async (targetDir, release) => {
108
- await downloadStarter(targetDir, void 0, release, { announce: false });
109
- });
110
- try {
111
- await download(oldStarterDir, previousRelease);
112
- await download(newStarterDir, targetRelease);
113
- const oldManifest = readStarterReleaseManifest(oldStarterDir, previousRelease);
114
- const newManifest = readStarterReleaseManifest(newStarterDir, targetRelease);
115
- const ownership = mergeStarterOwnershipContracts(
116
- oldManifest.ownership,
117
- newManifest.ownership
118
- );
119
- const plan = planStarterRuntimeSync(
120
- oldStarterDir,
121
- newStarterDir,
122
- options.targetDir,
123
- ownership
124
- );
125
- if (!options.apply) {
126
- return {
127
- previousRelease,
128
- targetRelease,
129
- plan,
130
- isUpToDate: false,
131
- applied: false
132
- };
133
- }
134
- if (plan.conflictPaths.length > 0) {
135
- throw new Error("Resolve starter synchronization conflicts before applying.");
136
- }
137
- if (readDownstreamManifest(options.targetDir, manifestPath) !== downstreamManifest) {
138
- throw new Error("The project starter manifest changed during update planning.");
139
- }
140
- applyStarterRuntimeSyncPlan(
141
- newStarterDir,
142
- options.targetDir,
143
- plan,
144
- ownership,
145
- {
146
- confirmed: true,
147
- provenance: {
148
- sourcePath: join(newStarterDir, manifestPath),
149
- targetPath: join(options.targetDir, manifestPath),
150
- expectedSha256: previousRelease.source.manifestSha256
151
- }
152
- }
153
- );
154
- return {
155
- previousRelease,
156
- targetRelease,
157
- plan,
158
- isUpToDate: false,
159
- applied: true
160
- };
161
- } finally {
162
- rmSync(workspace, { recursive: true, force: true });
163
- }
164
- }
12
+ import "./chunk-LKI5X3H2.js";
13
+ import "./chunk-D7ANFHIL.js";
165
14
  export {
15
+ fetchStarterReleaseHint,
16
+ resolveManualReviewMutations,
166
17
  resolveStarterReleaseFromManifest,
18
+ resolveStarterUpdateBase,
19
+ starterReleaseProviderError,
167
20
  updateStarterProject
168
21
  };
@@ -0,0 +1,18 @@
1
+ import { ScaffoldRelease } from './release.js';
2
+ import { StarterUpdateDownload } from './starter-update.js';
3
+ import './starter-sync.js';
4
+
5
+ /** Immutable candidate and trusted historical baselines used by release gates. */
6
+ interface UpgradeCanaryOptions {
7
+ target: ScaffoldRelease;
8
+ candidateDirectory: string;
9
+ releases?: readonly ScaffoldRelease[];
10
+ current?: ScaffoldRelease;
11
+ download?: StarterUpdateDownload;
12
+ }
13
+ /** Reject noncanonical sources before any gate downloads an archive. */
14
+ declare function assertCanonicalScaffoldUpgradeRelease(release: ScaffoldRelease): void;
15
+ /** Run the current and oldest retained bases through the real complete updater. */
16
+ declare function verifyScaffoldUpgrades(options: UpgradeCanaryOptions): Promise<string[]>;
17
+
18
+ export { type UpgradeCanaryOptions, assertCanonicalScaffoldUpgradeRelease, verifyScaffoldUpgrades };
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ updateStarterProject
4
+ } from "./chunk-J5V4UJXD.js";
5
+ import {
6
+ downloadStarter
7
+ } from "./chunk-VHIX2IYK.js";
8
+ import "./chunk-L2YCVQBR.js";
9
+ import {
10
+ classifyStarterPath,
11
+ mergeStarterOwnershipContracts,
12
+ readStarterReleaseManifest
13
+ } from "./chunk-LKI5X3H2.js";
14
+ import {
15
+ STABLE_SCAFFOLD_RELEASE,
16
+ SUPPORTED_SCAFFOLD_RELEASES
17
+ } from "./chunk-D7ANFHIL.js";
18
+
19
+ // src/starter-upgrade-canary.ts
20
+ import { createHash } from "crypto";
21
+ import { cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "fs";
22
+ import { tmpdir } from "os";
23
+ import { dirname, join } from "path";
24
+ import { isDeepStrictEqual } from "util";
25
+ function files(root, prefix = "") {
26
+ const result = /* @__PURE__ */ new Map();
27
+ for (const entry of readdirSync(join(root, prefix))) {
28
+ const path = prefix ? `${prefix}/${entry}` : entry;
29
+ const stat = lstatSync(join(root, path));
30
+ if (stat.isSymbolicLink()) throw new Error("Canary archives cannot contain symbolic links.");
31
+ if (stat.isDirectory()) for (const [key, bytes] of files(root, path)) result.set(key, bytes);
32
+ else if (stat.isFile()) result.set(path, readFileSync(join(root, path)));
33
+ else throw new Error("Canary archives must contain only regular files.");
34
+ }
35
+ return result;
36
+ }
37
+ function assertCanonicalScaffoldUpgradeRelease(release) {
38
+ if (release.source.repository !== "thallylabs/starter" || release.runtime.repository !== "thallylabs/thally" || !/^[a-f0-9]{40}$/.test(release.source.commitSha) || !/^[a-f0-9]{40}$/.test(release.source.treeSha) || !/^[a-f0-9]{40}$/.test(release.runtime.commitSha) || !/^[a-f0-9]{40}$/.test(release.runtime.treeSha) || !/^[a-f0-9]{64}$/.test(release.source.manifestSha256) || release.source.archiveUrl !== `https://codeload.github.com/thallylabs/starter/tar.gz/${release.source.commitSha}`) {
39
+ throw new Error("Historical upgrade canaries require immutable canonical releases.");
40
+ }
41
+ }
42
+ async function verifyScaffoldUpgrades(options) {
43
+ const { target } = options;
44
+ assertCanonicalScaffoldUpgradeRelease(target);
45
+ const targetManifest = readStarterReleaseManifest(options.candidateDirectory, target);
46
+ const targetFiles = files(options.candidateDirectory);
47
+ const history = [...options.releases ?? SUPPORTED_SCAFFOLD_RELEASES].filter((release) => release.source.manifestSha256 !== target.source.manifestSha256);
48
+ if (!history.length) throw new Error("Historical upgrade canary has no retained baseline.");
49
+ const current = options.current ?? STABLE_SCAFFOLD_RELEASE;
50
+ const bases = [current, history.at(-1)].filter((release, index, all) => all.findIndex((other) => other.source.manifestSha256 === release.source.manifestSha256) === index);
51
+ const catalog = [target, ...bases, ...history].filter((release, index, all) => all.findIndex((other) => other.source.manifestSha256 === release.source.manifestSha256) === index);
52
+ catalog.forEach(assertCanonicalScaffoldUpgradeRelease);
53
+ const workspace = mkdtempSync(join(tmpdir(), "thally-upgrade-canary-"));
54
+ const snapshots = /* @__PURE__ */ new Map();
55
+ const download = async (destination, release) => {
56
+ if (!catalog.some((known) => known.id === release.id && known.source.manifestSha256 === release.source.manifestSha256)) throw new Error("Canary requested an uncatalogued release.");
57
+ if (release.source.manifestSha256 === target.source.manifestSha256) cpSync(options.candidateDirectory, destination, { recursive: true });
58
+ else if (options.download) await options.download(destination, release);
59
+ else {
60
+ let snapshot = snapshots.get(release.source.manifestSha256);
61
+ if (!snapshot) {
62
+ snapshot = join(workspace, "archives", release.source.manifestSha256);
63
+ mkdirSync(snapshot, { recursive: true });
64
+ await downloadStarter(snapshot, void 0, release, { announce: false });
65
+ readStarterReleaseManifest(snapshot, release);
66
+ files(snapshot);
67
+ snapshots.set(release.source.manifestSha256, snapshot);
68
+ }
69
+ cpSync(snapshot, destination, { recursive: true });
70
+ }
71
+ readStarterReleaseManifest(destination, release);
72
+ files(destination);
73
+ };
74
+ try {
75
+ for (const [index, base] of bases.entries()) {
76
+ const customer = join(workspace, String(index));
77
+ mkdirSync(customer);
78
+ await download(customer, base);
79
+ const oldManifest = readStarterReleaseManifest(customer, base);
80
+ const ownership = mergeStarterOwnershipContracts(oldManifest.ownership, targetManifest.ownership);
81
+ for (const [path, bytes] of [
82
+ ["src/content/__upgrade-canary.mdx", Buffer.from("---\ntitle: Upgrade canary\ndescription: Owner preservation\n---\nOwner content.\n")],
83
+ ["docs.json", Buffer.from('{"name":"Customer upgrade canary","navigation":[]}\n')]
84
+ ]) {
85
+ if (classifyStarterPath(path, ownership) !== "protected") throw new Error(`Canary owner path is not protected: ${path}`);
86
+ mkdirSync(dirname(join(customer, path)), { recursive: true });
87
+ writeFileSync(join(customer, path), bytes);
88
+ }
89
+ const packagePath = join(customer, "package.json");
90
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
91
+ packageJson.name = "customer-upgrade-canary";
92
+ writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}
93
+ `);
94
+ const before = files(customer);
95
+ const plan = await updateStarterProject({ targetDir: customer, targetRelease: target, releases: catalog, download });
96
+ if (plan.plan.conflictPaths.length) throw new Error(`Historical upgrade conflicts: ${base.id}`);
97
+ await updateStarterProject({ targetDir: customer, targetRelease: target, releases: catalog, download, apply: true, confirmed: true });
98
+ const after = files(customer);
99
+ for (const [path, bytes] of before) if (path !== target.source.manifestPath && classifyStarterPath(path, ownership) === "protected" && !after.get(path)?.equals(bytes)) throw new Error(`Upgrade changed owner bytes: ${path}`);
100
+ for (const path of /* @__PURE__ */ new Set([...targetFiles.keys(), ...after.keys()])) if (classifyStarterPath(path, ownership) === "syncable" && (!targetFiles.has(path) || !after.has(path) || !targetFiles.get(path).equals(after.get(path)))) throw new Error(`Upgrade runtime differs from candidate: ${path}`);
101
+ if (JSON.parse(readFileSync(packagePath, "utf8")).name !== "customer-upgrade-canary") throw new Error("Upgrade lost independent package customization.");
102
+ for (const path of ["package.json", "package-lock.json"]) {
103
+ const actual = JSON.parse(readFileSync(join(customer, path), "utf8"));
104
+ const expected = JSON.parse(readFileSync(join(options.candidateDirectory, path), "utf8"));
105
+ delete actual.name;
106
+ delete expected.name;
107
+ if (actual.packages?.[""]) delete actual.packages[""].name;
108
+ if (expected.packages?.[""]) delete expected.packages[""].name;
109
+ if (!isDeepStrictEqual(actual, expected)) throw new Error(`Upgrade package inputs differ from candidate: ${path}`);
110
+ }
111
+ const hash = createHash("sha256").update(readFileSync(join(customer, target.source.manifestPath))).digest("hex");
112
+ if (hash !== target.source.manifestSha256) throw new Error("Upgrade did not advance exact target provenance.");
113
+ }
114
+ return bases.map((base) => base.id);
115
+ } finally {
116
+ rmSync(workspace, { recursive: true, force: true });
117
+ }
118
+ }
119
+ export {
120
+ assertCanonicalScaffoldUpgradeRelease,
121
+ verifyScaffoldUpgrades
122
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-thally-docs",
3
- "version": "0.10.88",
3
+ "version": "0.10.90",
4
4
  "description": "Scaffold an open-source Thally docs site for people, search engines, and AI tools.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -33,6 +33,10 @@
33
33
  "./terminal": {
34
34
  "types": "./dist/terminal.d.ts",
35
35
  "import": "./dist/terminal.js"
36
+ },
37
+ "./starter-upgrade-canary": {
38
+ "types": "./dist/starter-upgrade-canary.d.ts",
39
+ "import": "./dist/starter-upgrade-canary.js"
36
40
  }
37
41
  },
38
42
  "files": [