create-thally-docs 0.10.118 → 0.10.119

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.
@@ -0,0 +1,671 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ findPublicSpecs,
4
+ shadowNote
5
+ } from "./chunk-KDLP27DS.js";
6
+ import {
7
+ initGit,
8
+ installDeps,
9
+ runProjectCommand,
10
+ scaffold
11
+ } from "./chunk-6MT76XGS.js";
12
+ import {
13
+ pruneMissingSiteLinks
14
+ } from "./chunk-UG3OXVLE.js";
15
+ import {
16
+ resolveStarterUpdateBase
17
+ } from "./chunk-7BAJKUBN.js";
18
+ import {
19
+ downloadStarter
20
+ } from "./chunk-44WRU5LD.js";
21
+ import {
22
+ terminal
23
+ } from "./chunk-L2YCVQBR.js";
24
+ import {
25
+ areStarterFileContentsEquivalent
26
+ } from "./chunk-C4DPGWFM.js";
27
+
28
+ // src/migrate/index.ts
29
+ import { createHash } from "crypto";
30
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync3, readFileSync as readFileSync4, realpathSync as realpathSync2, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "fs";
31
+ import { tmpdir as tmpdir2 } from "os";
32
+ import { basename, join as join5, resolve as resolve2 } from "path";
33
+ import {
34
+ cloneGitHubRepository,
35
+ importSourceRef,
36
+ hydrateRemoteApiSpecs,
37
+ hydrateUrlOpenApi,
38
+ migrateUrl,
39
+ runUrlMigration,
40
+ parseGitHubRepositoryUrl,
41
+ sourceRefOversizeReason
42
+ } from "@thallylabs/migrate";
43
+
44
+ // src/migration-work.ts
45
+ import { Worker } from "worker_threads";
46
+ async function runMigrationWork(request, options) {
47
+ const worker = new Worker(options.workerUrl ?? new URL("./migration-worker.js", import.meta.url), {
48
+ workerData: request,
49
+ stdout: true,
50
+ stderr: true
51
+ });
52
+ worker.stdout.resume();
53
+ worker.stderr.resume();
54
+ try {
55
+ return await new Promise((complete, reject) => {
56
+ worker.once("message", (response) => {
57
+ if (response.error) reject(response.error);
58
+ else complete(response.result);
59
+ });
60
+ worker.once("error", reject);
61
+ worker.once("exit", (code) => reject(new Error(`Migration worker exited before returning a result (exit ${code}).`)));
62
+ });
63
+ } finally {
64
+ await worker.terminate();
65
+ }
66
+ }
67
+ function convertRepository(options, workerOptions = {}) {
68
+ return runMigrationWork({ operation: "repository", args: [options] }, workerOptions);
69
+ }
70
+ function renderFiles(bundle, options, workerOptions = {}) {
71
+ return runMigrationWork({ operation: "render", args: [bundle, options] }, workerOptions);
72
+ }
73
+ function discoverUrl(options, workerOptions = {}) {
74
+ return runMigrationWork({ operation: "url", args: [options] }, workerOptions);
75
+ }
76
+ function checkContent(projectDir, workerOptions = {}) {
77
+ return runMigrationWork({ operation: "check", args: [projectDir] }, workerOptions);
78
+ }
79
+
80
+ // src/migrate/validate.ts
81
+ import { existsSync, readFileSync } from "fs";
82
+ import { join } from "path";
83
+ async function validateMigration(projectDir, skip = false, installationFailed = false) {
84
+ const result = { content: "skipped", build: "skipped", messages: [], diagnostics: [] };
85
+ if (skip) {
86
+ result.messages.push("Validation was explicitly skipped; this import is not verified.");
87
+ return result;
88
+ }
89
+ try {
90
+ const isQuiet = terminal.isRich && !terminal.isVerbose;
91
+ await terminal.step("Validate content", async () => {
92
+ const checked = await checkContent(projectDir);
93
+ const code = checked.code;
94
+ result.diagnostics = checked.diagnostics;
95
+ if (!isQuiet) {
96
+ for (const issue of checked.diagnostics) {
97
+ const location = issue.file ? `file=${issue.file}${issue.line ? `,line=${issue.line}` : ""}` : "";
98
+ console.log(`::${issue.severity} ${location}::${issue.message}`);
99
+ }
100
+ const errors = checked.diagnostics.filter((issue) => issue.severity === "error").length;
101
+ const warnings = checked.diagnostics.length - errors;
102
+ console.log(`
103
+ thally check: ${errors} error(s), ${warnings} warning(s)`);
104
+ }
105
+ if (isQuiet) {
106
+ for (const issue of result.diagnostics) {
107
+ result.messages.push(`${issue.file ? `${issue.file}${issue.line ? `:${issue.line}` : ""}: ` : ""}${issue.message}`);
108
+ }
109
+ }
110
+ if (code !== 0) throw new Error("Review the content diagnostics; source links are not silently rewritten.");
111
+ }, "Content validated");
112
+ result.content = "passed";
113
+ } catch (error) {
114
+ if ([130, 143].includes(error.exitCode ?? 0)) throw error;
115
+ result.content = "failed";
116
+ result.messages.push(`Content validation failed: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ if (installationFailed) {
119
+ result.build = "failed";
120
+ result.messages.push("Dependency installation failed; production build was not attempted. Review the installation output before retrying.");
121
+ return result;
122
+ }
123
+ const packagePath = join(projectDir, "package.json");
124
+ try {
125
+ const manifest = existsSync(packagePath) ? JSON.parse(readFileSync(packagePath, "utf8")) : void 0;
126
+ if (!manifest?.scripts?.build) {
127
+ result.messages.push("No build script is available; production rendering is not verified.");
128
+ return result;
129
+ }
130
+ try {
131
+ await terminal.step("Validate production build", (update) => runProjectCommand("npm", ["run", "build"], projectDir, 10 * 60 * 1e3, (message) => update(`Production build: ${message}`)), "Production build validated");
132
+ result.build = "passed";
133
+ } catch (error) {
134
+ const failure = error;
135
+ if (failure.output?.trim()) terminal.error(failure.output.trimEnd());
136
+ throw error;
137
+ }
138
+ } catch (error) {
139
+ if ([130, 143].includes(error.exitCode ?? 0)) throw error;
140
+ result.build = "failed";
141
+ result.messages.push(`Production build failed: ${error instanceof Error ? error.message : String(error)}`);
142
+ }
143
+ return result;
144
+ }
145
+
146
+ // src/migrate/local-source.ts
147
+ import { copyFileSync, existsSync as existsSync2, lstatSync, mkdirSync, readdirSync, realpathSync, statSync } from "fs";
148
+ import { detectRepositoryDocsDir, projectMintlifyNavigation, readMintlifyConfig } from "@thallylabs/migrate";
149
+ import { dirname, extname, isAbsolute, join as join2, relative, resolve, sep } from "path";
150
+ var generatedDirectories = /* @__PURE__ */ new Set(["dist", "build", "coverage"]);
151
+ var extensions = /* @__PURE__ */ new Set([".md", ".mdx", ".json", ".yaml", ".yml", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".css", ".scss", ".sass", ".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".m4a", ".mp3", ".mp4", ".ogg", ".pdf", ".png", ".svg", ".wav", ".webm", ".webp"]);
152
+ function contains(root, candidate) {
153
+ const path = relative(root, candidate);
154
+ return !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`);
155
+ }
156
+ function physicalPath(path) {
157
+ if (existsSync2(path)) return realpathSync(path);
158
+ const parent = dirname(path);
159
+ return parent === path ? path : join2(physicalPath(parent), relative(parent, path));
160
+ }
161
+ function assertSeparateMigrationDirectories(source, output) {
162
+ const sourceRoot = realpathSync(resolve(source));
163
+ if (!statSync(sourceRoot).isDirectory()) throw new Error("The local migration source must be a directory.");
164
+ const target = physicalPath(resolve(output));
165
+ if (contains(sourceRoot, target) || contains(target, sourceRoot)) {
166
+ throw new Error("Local migration source and output directories must not overlap. Choose a separate output directory.");
167
+ }
168
+ }
169
+ function assertMigrationDestination(projectDir, candidate = "") {
170
+ const root = resolve(projectDir);
171
+ const target = resolve(root, candidate);
172
+ if (!contains(root, target)) throw new Error(`Generated migration path escapes the project: ${candidate}`);
173
+ const parts = relative(root, target).split(sep).filter(Boolean);
174
+ let current = root;
175
+ for (const [index, part] of ["", ...parts].entries()) {
176
+ if (part) current = join2(current, part);
177
+ try {
178
+ const entry = lstatSync(current);
179
+ if (entry.isSymbolicLink()) throw new Error(`Migration destination must not traverse a symbolic link: ${current}`);
180
+ if ((index < parts.length || parts.length === 0) && !entry.isDirectory()) throw new Error(`Migration destination parent must be a directory: ${current}`);
181
+ } catch (error) {
182
+ if (error.code !== "ENOENT") throw error;
183
+ }
184
+ }
185
+ return target;
186
+ }
187
+ function snapshotLocalMigration(source, snapshot, docsDir) {
188
+ const root = realpathSync(resolve(source));
189
+ let files = 0;
190
+ let bytes = 0;
191
+ const visited = /* @__PURE__ */ new Set();
192
+ const safeName = (name) => name === ".mintignore" || !name.startsWith(".") && name !== "node_modules";
193
+ const detectedDocsDir = detectRepositoryDocsDir(root);
194
+ const conventionalDocsDir = ["docs", "documentation", "content", "pages", "src/content", "src/pages", "guide", "guides"].find((path) => {
195
+ try {
196
+ return lstatSync(join2(root, path)).isDirectory();
197
+ } catch {
198
+ return false;
199
+ }
200
+ });
201
+ const selectedDocsDir = docsDir ?? (detectedDocsDir || conventionalDocsDir);
202
+ const contentRoot = selectedDocsDir ? resolve(root, selectedDocsDir) : void 0;
203
+ if (contentRoot && (!contains(root, contentRoot) || existsSync2(contentRoot) && !contains(root, realpathSync(contentRoot)))) throw new Error("The selected documentation directory must remain inside the local source.");
204
+ const references = /* @__PURE__ */ new Set();
205
+ for (const configRoot of /* @__PURE__ */ new Set([root, ...contentRoot ? [contentRoot] : []])) {
206
+ const config = readMintlifyConfig(configRoot);
207
+ if (!config) continue;
208
+ for (const { ref } of projectMintlifyNavigation(config).pageReferences) {
209
+ const path = resolve(configRoot, ref.replace(/^\//, "").replace(/\.mdx?$/i, ""));
210
+ if (contains(root, path)) references.add(path);
211
+ }
212
+ }
213
+ const includesGeneratedName = (directory) => relative(root, directory).split(sep).some((name) => generatedDirectories.has(name));
214
+ const shouldCopyDirectory = (directory) => !includesGeneratedName(directory) || Boolean(contentRoot && contains(contentRoot, directory)) || [...references].some((page) => contains(directory, page));
215
+ function walk(directory, destination) {
216
+ const real = realpathSync(directory);
217
+ if (!contains(root, real) || visited.has(real)) return;
218
+ if (relative(root, real).split(sep).some((name) => name && !safeName(name))) return;
219
+ visited.add(real);
220
+ mkdirSync(destination, { recursive: true });
221
+ for (const entry of readdirSync(real, { withFileTypes: true })) {
222
+ if (!safeName(entry.name)) continue;
223
+ const path = join2(real, entry.name);
224
+ let actual;
225
+ try {
226
+ actual = realpathSync(path);
227
+ } catch {
228
+ continue;
229
+ }
230
+ if (!contains(root, actual) || relative(root, actual).split(sep).some((name) => !safeName(name))) continue;
231
+ const info = statSync(actual);
232
+ const target = join2(destination, entry.name);
233
+ if (info.isDirectory() && shouldCopyDirectory(path)) walk(actual, target);
234
+ else if (info.isFile() && (entry.name === ".mintignore" || extensions.has(extname(actual).toLowerCase()))) {
235
+ if (++files > 1e5 || (bytes += info.size) > 256e6 || info.size > 16e6) {
236
+ throw new Error("Local documentation exceeds the migration input limit (100000 files, 256 MB total, 16 MB per file). Select a smaller source directory.");
237
+ }
238
+ copyFileSync(actual, target);
239
+ }
240
+ }
241
+ }
242
+ walk(root, snapshot);
243
+ }
244
+
245
+ // src/migrate/into.ts
246
+ import { existsSync as existsSync3, lstatSync as lstatSync2, mkdtempSync, readFileSync as readFileSync2, readdirSync as readdirSync2, rmSync } from "fs";
247
+ import { tmpdir } from "os";
248
+ import { join as join3, relative as relative2 } from "path";
249
+ import { isDeepStrictEqual } from "util";
250
+ function seedFiles(directory) {
251
+ if (!existsSync3(directory)) return [];
252
+ return readdirSync2(directory, { withFileTypes: true }).flatMap((entry) => {
253
+ if (entry.isSymbolicLink()) throw new Error("Starter seed content contains a symbolic link.");
254
+ const path = join3(directory, entry.name);
255
+ return entry.isDirectory() ? seedFiles(path) : entry.isFile() ? [path] : [];
256
+ });
257
+ }
258
+ function untouchedSeedFiles(projectDir, baselineDir, existing) {
259
+ const baseline = JSON.parse(readFileSync2(join3(baselineDir, "docs.json"), "utf8"));
260
+ if (!isDeepStrictEqual(existing.tabs, baseline.tabs)) return void 0;
261
+ const seed = /* @__PURE__ */ new Map();
262
+ for (const path of seedFiles(join3(baselineDir, "src/content"))) {
263
+ const name = relative2(baselineDir, path).replace(/\\/g, "/");
264
+ const target = assertMigrationDestination(projectDir, name);
265
+ if (!existsSync3(target) || !lstatSync2(target).isFile()) return void 0;
266
+ const current = readFileSync2(target);
267
+ if (!areStarterFileContentsEquivalent(name, current, readFileSync2(path))) return void 0;
268
+ seed.set(name, current);
269
+ }
270
+ return seed;
271
+ }
272
+ async function migrationExistingConfig(projectDir, existing, warnings) {
273
+ const preserved = { config: existing, seedFiles: /* @__PURE__ */ new Map(), preconditions: /* @__PURE__ */ new Map() };
274
+ if (!existing) return preserved;
275
+ const initialConfig = readFileSync2(assertMigrationDestination(projectDir, "docs.json"));
276
+ preserved.preconditions.set("docs.json", initialConfig);
277
+ const manifest = assertMigrationDestination(projectDir, "starter-release.json");
278
+ if (!existsSync3(manifest)) return preserved;
279
+ const temporary = mkdtempSync(join3(tmpdir(), "thally-migration-seed-"));
280
+ try {
281
+ const initialManifest = readFileSync2(manifest);
282
+ const { release } = await resolveStarterUpdateBase({ targetDir: projectDir });
283
+ await downloadStarter(temporary, void 0, release, { announce: false });
284
+ if (!initialManifest.equals(readFileSync2(manifest)) || !initialConfig.equals(readFileSync2(assertMigrationDestination(projectDir, "docs.json")))) throw new Error("The project changed during starter verification. Retry migration.");
285
+ const seed = untouchedSeedFiles(projectDir, temporary, existing);
286
+ if (seed) {
287
+ return {
288
+ config: { ...existing, tabs: [] },
289
+ seedFiles: seed,
290
+ preconditions: new Map([["starter-release.json", initialManifest], ["docs.json", initialConfig], ...seed]),
291
+ seedRelease: { id: release.id, sourceCommit: release.source.commitSha }
292
+ };
293
+ }
294
+ } catch (error) {
295
+ warnings.push({ code: "unsupported-config", message: `Starter navigation could not be verified and was preserved. ${error instanceof Error ? error.message : String(error)}` });
296
+ } finally {
297
+ rmSync(temporary, { recursive: true, force: true });
298
+ }
299
+ return preserved;
300
+ }
301
+
302
+ // src/migrate/materialize.ts
303
+ import { chmodSync, existsSync as existsSync4, lstatSync as lstatSync3, mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync as readFileSync3, renameSync, rmSync as rmSync2, writeFileSync } from "fs";
304
+ import { dirname as dirname2, join as join4 } from "path";
305
+ function migrationFileState(projectDir, path) {
306
+ const target = assertMigrationDestination(projectDir, path);
307
+ if (!existsSync4(target)) return {};
308
+ const entry = lstatSync3(target);
309
+ if (!entry.isFile()) throw new Error(`Migration destination must be a regular file: ${path}`);
310
+ return { content: readFileSync3(target), mode: entry.mode };
311
+ }
312
+ function same(left, right) {
313
+ return left === void 0 || right === void 0 ? left === right : left.equals(right);
314
+ }
315
+ function materializeMigration(projectDir, files, deletePaths, options = {}) {
316
+ const mutations = [...files.map((file) => ({ path: file.path, content: Buffer.from(file.content) })), ...deletePaths.map((path) => ({ path, content: void 0 }))];
317
+ if (new Set(mutations.map((mutation) => mutation.path)).size !== mutations.length) throw new Error("Migration contains conflicting destination mutations.");
318
+ const originals = new Map(mutations.map(({ path }) => [path, migrationFileState(projectDir, path)]));
319
+ const expectations = new Map([...originals].map(([path, state]) => [path, state.content]));
320
+ for (const [path, bytes] of options.preconditions ?? []) expectations.set(path, bytes);
321
+ const unchanged = (path, expected) => {
322
+ if (!same(migrationFileState(projectDir, path).content, expected)) throw new Error(`Migration destination changed after planning: ${path}. Retry without overwriting the owner edit.`);
323
+ };
324
+ const transaction = mkdtempSync2(join4(dirname2(projectDir), ".thally-migration-transaction-"));
325
+ const applied = [];
326
+ let retainBackup = false;
327
+ const temporaryPath = (area, path) => assertMigrationDestination(join4(transaction, area), path);
328
+ try {
329
+ for (const mutation of mutations) {
330
+ const original = originals.get(mutation.path);
331
+ if (original.content !== void 0) {
332
+ const backup = temporaryPath("backup", mutation.path);
333
+ mkdirSync2(dirname2(backup), { recursive: true });
334
+ writeFileSync(backup, original.content, { mode: original.mode });
335
+ if (original.mode !== void 0) chmodSync(backup, original.mode);
336
+ }
337
+ if (mutation.content !== void 0) {
338
+ const stage = temporaryPath("stage", mutation.path);
339
+ mkdirSync2(dirname2(stage), { recursive: true });
340
+ writeFileSync(stage, mutation.content, { mode: original.mode });
341
+ if (original.mode !== void 0) chmodSync(stage, original.mode);
342
+ }
343
+ }
344
+ for (const [path, expected] of expectations) unchanged(path, expected);
345
+ for (const [index, mutation] of mutations.entries()) {
346
+ unchanged(mutation.path, expectations.get(mutation.path));
347
+ const destination = assertMigrationDestination(projectDir, mutation.path);
348
+ if (mutation.content === void 0) rmSync2(destination, { force: true });
349
+ else {
350
+ mkdirSync2(dirname2(destination), { recursive: true });
351
+ renameSync(temporaryPath("stage", mutation.path), destination);
352
+ }
353
+ applied.push(mutation);
354
+ options.onMutationApplied?.(mutation.path, index);
355
+ }
356
+ } catch (error) {
357
+ const rollbackErrors = [];
358
+ for (const mutation of applied.reverse()) {
359
+ try {
360
+ unchanged(mutation.path, mutation.content);
361
+ const destination = assertMigrationDestination(projectDir, mutation.path);
362
+ const original = originals.get(mutation.path);
363
+ if (original.content === void 0) rmSync2(destination, { force: true });
364
+ else renameSync(temporaryPath("backup", mutation.path), destination);
365
+ } catch (failure) {
366
+ rollbackErrors.push(failure);
367
+ }
368
+ }
369
+ if (rollbackErrors.length) {
370
+ retainBackup = true;
371
+ throw new AggregateError([error, ...rollbackErrors], `Migration failed and some files changed during rollback. Original files are retained at ${transaction}/backup; preserve owner edits before recovery.`);
372
+ }
373
+ throw error;
374
+ } finally {
375
+ if (!retainBackup) rmSync2(transaction, { recursive: true, force: true });
376
+ }
377
+ }
378
+
379
+ // src/migrate/links.ts
380
+ import { literalRoute, migrationApiRoutes, pageEntryRoutes } from "@thallylabs/migrate";
381
+ var machineRoutes = /* @__PURE__ */ new Set(["/llms.txt", "/llms-full.txt", "/openapi.json", "/openapi.yaml", "/robots.txt", "/sitemap.xml", "/skill.md", "/AGENTS.md", "/auth.md"]);
382
+ function reportUnresolvedMigrationLinks(bundle) {
383
+ const routes = new Set([...bundle.pages.flatMap((page) => [...pageEntryRoutes(page), `/${page.navigationId}`, `/${page.id}.md`, `/api/docs/${page.id}`, `/api/markdown/${page.id}`]), ...migrationApiRoutes(bundle)].map((route) => literalRoute(route) ?? route));
384
+ routes.add("/");
385
+ for (const redirect of bundle.docsConfig.redirects ?? []) routes.add(literalRoute(redirect.source) ?? redirect.source);
386
+ for (const asset of bundle.assets) if (!asset.projectRelative) routes.add(literalRoute(`/${asset.path}`) ?? `/${asset.path}`);
387
+ for (const page of bundle.pages) {
388
+ const unknown = /* @__PURE__ */ new Set();
389
+ let fence;
390
+ for (const line of page.body.split("\n")) {
391
+ const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
392
+ if (marker) {
393
+ if (!fence) fence = marker[1];
394
+ else if (marker[1][0] === fence[0] && marker[1].length >= fence.length && !line.slice(marker[0].length).trim()) fence = void 0;
395
+ continue;
396
+ }
397
+ if (fence) continue;
398
+ const prose = line.replace(/`[^`]*`/g, "");
399
+ for (const match of prose.matchAll(/(?:\]\(|\bhref=["'])(\/(?!\/)[^\s)"']+)/g)) {
400
+ const target = match[1].split(/[?#]/, 1)[0].replace(/\/+$/, "") || "/";
401
+ if (!routes.has(literalRoute(target) ?? target) && !machineRoutes.has(target)) unknown.add(target);
402
+ }
403
+ }
404
+ if (unknown.size) bundle.warnings.push({ code: "unsupported-config", source: page.id, message: `Internal links have no imported destination and require review: ${[...unknown].slice(0, 20).join(", ")}${unknown.size > 20 ? `, and ${unknown.size - 20} more` : ""}.` });
405
+ }
406
+ }
407
+
408
+ // src/migrate/index.ts
409
+ function projectPath(projectDir, candidate) {
410
+ return assertMigrationDestination(projectDir, candidate);
411
+ }
412
+ function quarantineIgnoreFile(projectDir) {
413
+ const ignorePath = projectPath(projectDir, ".gitignore");
414
+ const existing = existsSync5(ignorePath) ? readFileSync4(ignorePath, "utf8") : "";
415
+ const rules = existing.split(/\r?\n/).map((line) => line.trim()).filter((line) => /^!?\/?migration-quarantine(?:\/(?:\*\*?)?)?$/.test(line));
416
+ if (rules.length > 0 && !rules[rules.length - 1].startsWith("!")) return;
417
+ const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
418
+ return { path: ".gitignore", content: `${existing}${separator}
419
+ # Access-restricted pages withheld by migration: local only, never commit or deploy
420
+ /migration-quarantine/
421
+ ` };
422
+ }
423
+ function readExistingConfig(projectDir) {
424
+ const configPath = projectPath(projectDir, "docs.json");
425
+ if (!existsSync5(configPath)) return void 0;
426
+ return JSON.parse(readFileSync4(configPath, "utf8"));
427
+ }
428
+ function resetFreshMigrationContent(projectDir) {
429
+ const contentDirectory = projectPath(projectDir, "src/content");
430
+ rmSync3(contentDirectory, { recursive: true, force: true });
431
+ mkdirSync3(contentDirectory, { recursive: true });
432
+ for (const sampleSpec of ["openapi.yaml", "openapi.json"]) {
433
+ rmSync3(projectPath(projectDir, sampleSpec), { force: true });
434
+ }
435
+ }
436
+ async function fetchSourceRefs(mappings, warnings) {
437
+ const imports = [];
438
+ for (const mapping of mappings) {
439
+ const root = mkdtempSync3(join5(tmpdir2(), "thally-source-ref-"));
440
+ try {
441
+ const source = parseGitHubRepositoryUrl(`https://github.com/${mapping.repo}`);
442
+ const tooLarge = await sourceRefOversizeReason(mapping.repo);
443
+ if (tooLarge) throw new Error(tooLarge);
444
+ const cloneDir = join5(root, "repository");
445
+ await cloneGitHubRepository(source, cloneDir, warnings, { skipSubmodules: true });
446
+ imports.push(importSourceRef(mapping, cloneDir));
447
+ } catch (error) {
448
+ warnings.push({ code: "fetch-failed", message: `sourceRef ${mapping.repo} was not imported: ${error instanceof Error ? error.message : String(error)}` });
449
+ } finally {
450
+ rmSync3(root, { recursive: true, force: true });
451
+ }
452
+ }
453
+ return imports;
454
+ }
455
+ async function discoverMigration(options) {
456
+ if (options.sourceDirectory) {
457
+ assertSeparateMigrationDirectories(options.sourceDirectory, options.projectDir);
458
+ const temporaryRoot2 = mkdtempSync3(join5(tmpdir2(), "thally-local-migrate-"));
459
+ try {
460
+ const snapshot = join5(temporaryRoot2, "source");
461
+ snapshotLocalMigration(options.sourceDirectory, snapshot, options.docsDir);
462
+ const bundle = await terminal.step("Converting local documentation", () => convertRepository({
463
+ repositoryDir: snapshot,
464
+ sourceUrl: "https://local.invalid/selected-directory",
465
+ // Namespace components stably without exposing the private checkout path.
466
+ componentNamespace: `local-${createHash("sha256").update(realpathSync2(options.sourceDirectory)).digest("hex").slice(0, 16)}`,
467
+ docsDir: options.docsDir,
468
+ platform: options.platform
469
+ }), "Local documentation converted");
470
+ return hydrateRemoteApiSpecs(bundle, options.fetcher);
471
+ } finally {
472
+ rmSync3(temporaryRoot2, { recursive: true, force: true });
473
+ }
474
+ }
475
+ const url = new URL(options.sourceUrl);
476
+ if (url.hostname.toLowerCase() !== "github.com") {
477
+ if (options.sourceRefs?.length) terminal.warn("--source-ref applies only to GitHub repository sources and was ignored.");
478
+ return terminal.step("Discovering public documentation", () => {
479
+ const discovery = {
480
+ sourceUrl: options.sourceUrl,
481
+ platform: options.platform,
482
+ maxPages: options.maxPages,
483
+ openapiUrl: options.openapiUrl
484
+ };
485
+ if (options.urlMigration) return runUrlMigration({ ...discovery, fetcher: options.fetcher, ...options.urlMigration }).then((run) => options.openapiUrl ? hydrateUrlOpenApi(run.bundle, options.openapiUrl, options.fetcher) : run.bundle);
486
+ return options.fetcher ? migrateUrl({ ...discovery, fetcher: options.fetcher }) : discoverUrl(discovery);
487
+ }, "Public documentation discovered");
488
+ }
489
+ const source = parseGitHubRepositoryUrl(options.sourceUrl);
490
+ if (options.branch) source.branch = options.branch;
491
+ const temporaryRoot = mkdtempSync3(join5(tmpdir2(), "thally-migrate-"));
492
+ const cloneDir = join5(temporaryRoot, "repository");
493
+ try {
494
+ const cloneWarnings = [];
495
+ await terminal.step(`Cloning ${source.owner}/${source.repo}`, () => cloneGitHubRepository(source, cloneDir, cloneWarnings), "Source repository cloned");
496
+ const sourceRefs = options.sourceRefs?.length ? await terminal.step("Importing sourceRef repositories", () => fetchSourceRefs(options.sourceRefs ?? [], cloneWarnings), "sourceRef repositories imported") : [];
497
+ const bundle = await terminal.step("Converting repository documentation", () => convertRepository({
498
+ sourceRefs,
499
+ repositoryDir: cloneDir,
500
+ sourceUrl: options.sourceUrl,
501
+ docsDir: options.docsDir ?? (source.docsDir || void 0),
502
+ platform: options.platform
503
+ }), "Repository documentation converted");
504
+ const hydrated = await terminal.step("Loading remote API specifications", () => hydrateRemoteApiSpecs(bundle, options.fetcher), "Remote API specifications loaded");
505
+ return cloneWarnings.length > 0 ? { ...hydrated, warnings: [...cloneWarnings, ...hydrated.warnings] } : hydrated;
506
+ } finally {
507
+ rmSync3(temporaryRoot, { recursive: true, force: true });
508
+ }
509
+ }
510
+ function publicSpecWarning(projectDir, bundle) {
511
+ const { specs, skipped } = findPublicSpecs(projectDir);
512
+ if (specs.length === 0 && skipped.length === 0) return null;
513
+ const migrated = new Set(bundle.assets.filter((asset) => asset.projectRelative).map((asset) => basename(asset.path)));
514
+ const lines = [
515
+ ...specs.map((spec) => `${spec.path}${migrated.has(basename(spec.path)) ? " (same file name as a migrated spec)" : ""}${spec.hasHiddenOperations ? " (contains x-excluded/x-hidden operations)" : ""}${shadowNote(spec.urlPath) ? ` (answers /${spec.urlPath} in place of the filtered specification)` : ""}`),
516
+ ...skipped.map((path) => `${path} (too large to inspect)`)
517
+ ];
518
+ return {
519
+ code: "unsupported-config",
520
+ message: `Existing OpenAPI files under public/ are served publicly as-is: ${lines.join("; ")}. Migrated specs are written to openapi/, and re-running migration does not remove old copies from public/. Delete any old copy manually (nothing was deleted), or its hidden operations stay downloadable.`
521
+ };
522
+ }
523
+ async function migrateDocs(options) {
524
+ const projectDir = resolve2(options.projectDir);
525
+ assertMigrationDestination(projectDir);
526
+ const bundle = await discoverMigration(options);
527
+ reportUnresolvedMigrationLinks(bundle);
528
+ if (!options.into) {
529
+ terminal.detail("Project", projectDir);
530
+ await scaffold({
531
+ projectDir,
532
+ projectName: options.projectName ?? bundle.site?.name ?? "My Docs",
533
+ description: bundle.site?.description ?? `Documentation migrated from ${options.sourceDirectory ? basename(resolve2(options.sourceDirectory)) : new URL(options.sourceUrl).hostname}`,
534
+ brandPreset: "primary",
535
+ // Source provenance is not the destination repository. Migrated pages
536
+ // live at new paths, so source URLs cannot power edit/issue actions.
537
+ repoUrl: "",
538
+ doInstall: false,
539
+ colors: bundle.site?.colors
540
+ });
541
+ resetFreshMigrationContent(projectDir);
542
+ pruneMissingSiteLinks(projectDir, new Set(bundle.pages.map((page) => page.id)));
543
+ } else if (!existsSync5(projectDir)) {
544
+ throw new Error(`Project directory "${projectDir}" does not exist. Use without --into to scaffold a new one.`);
545
+ }
546
+ if (!options.into) {
547
+ const starterConfig = readExistingConfig(projectDir);
548
+ if (starterConfig?.markdown) bundle.docsConfig.markdown ??= starterConfig.markdown;
549
+ if (!bundle.docsConfig.navbar?.primary) {
550
+ bundle.docsConfig.navbar = { ...bundle.docsConfig.navbar, primary: null };
551
+ }
552
+ bundle.docsConfig.i18n ??= { defaultLocale: "en", locales: [{ code: "en", label: "English" }] };
553
+ }
554
+ if (options.into) {
555
+ const stale = publicSpecWarning(projectDir, bundle);
556
+ if (stale) bundle.warnings.unshift(stale);
557
+ }
558
+ const existingPlan = options.into ? await migrationExistingConfig(projectDir, readExistingConfig(projectDir), bundle.warnings) : void 0;
559
+ const registryPath = projectPath(projectDir, "src/mdx/custom-components.tsx");
560
+ const registryBytes = existsSync5(registryPath) ? readFileSync4(registryPath) : void 0;
561
+ existingPlan?.preconditions.set("src/mdx/custom-components.tsx", registryBytes);
562
+ const rendered = await terminal.step("Rendering documentation", async () => renderFiles(bundle, {
563
+ existingConfig: existingPlan?.config,
564
+ existingComponentRegistry: registryBytes?.toString("utf8")
565
+ }), "Documentation rendered");
566
+ const conflicts = [];
567
+ const writePreconditions = new Map(existingPlan?.preconditions);
568
+ const writable = rendered.filter((file) => {
569
+ const path = projectPath(projectDir, file.path);
570
+ const current = existsSync5(path) ? readFileSync4(path) : void 0;
571
+ if (!writePreconditions.has(file.path)) writePreconditions.set(file.path, current);
572
+ if (!options.into || file.path === "docs.json" || file.path === "src/mdx/custom-components.tsx" || !current) return true;
573
+ if (current.equals(Buffer.from(file.content)) || existingPlan?.seedFiles.get(file.path)?.equals(current)) return true;
574
+ conflicts.push(file.path);
575
+ return false;
576
+ });
577
+ const conflictPaths = new Set(conflicts);
578
+ const pagesWritten = bundle.pages.filter((page) => !conflictPaths.has(`src/content/${page.id}.mdx`)).length;
579
+ const assetsWritten = bundle.assets.filter((asset) => !conflictPaths.has(asset.projectRelative ? asset.path : `public/${asset.path}`)).length;
580
+ if (conflicts.length) bundle.warnings.push({ code: "unsupported-config", message: `Existing authored files were preserved because imported content conflicts: ${conflicts.join(", ")}. Resolve these files before publishing.` });
581
+ const incomingPaths = new Set(rendered.map((file) => file.path));
582
+ const removedSeedPaths = [...existingPlan?.seedFiles.keys() ?? []].filter((path) => !incomingPaths.has(path));
583
+ const quarantineIgnore = bundle.quarantinedFiles?.length ? quarantineIgnoreFile(projectDir) : void 0;
584
+ if (quarantineIgnore) {
585
+ writable.push(quarantineIgnore);
586
+ const ignorePath = projectPath(projectDir, ".gitignore");
587
+ writePreconditions.set(".gitignore", existsSync5(ignorePath) ? readFileSync4(ignorePath) : void 0);
588
+ }
589
+ await terminal.step("Writing documentation", async () => {
590
+ projectPath(projectDir, "migration-report.json");
591
+ materializeMigration(projectDir, writable, removedSeedPaths, { preconditions: writePreconditions });
592
+ }, `Imported ${pagesWritten} pages and ${assetsWritten} assets from ${bundle.platform}`);
593
+ const format = (warning) => `${warning.message}${warning.source ? ` (${warning.source})` : ""}`;
594
+ for (const warning of bundle.warnings.filter((item) => item.code !== "gated-page")) terminal.warn(format(warning));
595
+ const gatedWarnings = bundle.warnings.filter((item) => item.code === "gated-page");
596
+ if (gatedWarnings.length > 0) {
597
+ terminal.warn("ACCESS-RESTRICTED CONTENT \u2014 review before publishing");
598
+ for (const warning of gatedWarnings) terminal.warn(format(warning));
599
+ if (bundle.droppedGatedPages) {
600
+ terminal.warn(`${bundle.droppedGatedPages} access-restricted page(s) were dropped by the file limit: not published and not saved under migration-quarantine/; recover them from the source repository.`);
601
+ }
602
+ if (bundle.quarantinedFiles?.length || gatedWarnings.some((warning) => !/dashboard access settings/i.test(warning.message))) {
603
+ terminal.warn("Before publishing, review migration-quarantine/assets/ and the dashboard access settings of the source site.");
604
+ }
605
+ }
606
+ let installationFailed = false;
607
+ if (!options.skipValidation) terminal.info("Migration validation runs project code locally, including imported MDX and components.");
608
+ if (!options.into && !options.skipValidation) {
609
+ try {
610
+ await installDeps(projectDir);
611
+ } catch (error) {
612
+ const exitCode = error.exitCode;
613
+ if (exitCode === 130 || exitCode === 143) throw error;
614
+ installationFailed = true;
615
+ }
616
+ }
617
+ terminal.info("Validating imported documentation...");
618
+ const validation = await validateMigration(projectDir, options.skipValidation, installationFailed);
619
+ if (conflicts.length) {
620
+ validation.content = "failed";
621
+ validation.messages.push(`${conflicts.length} existing file(s) conflict with imported content; owner files were preserved.`);
622
+ }
623
+ if (bundle.urlMigrationValidation && bundle.urlMigrationValidation.status !== "passed") {
624
+ validation.content = "failed";
625
+ validation.messages.push(`Source evidence validation is ${bundle.urlMigrationValidation.status}; ${bundle.urlMigrationValidation.issues.length} issue(s) require review.`);
626
+ }
627
+ const quarantinedPages = (bundle.quarantinedFiles ?? []).filter((file) => /\.mdx?$/i.test(file.path)).length;
628
+ const reportPath = projectPath(projectDir, "migration-report.json");
629
+ writeFileSync2(reportPath, `${JSON.stringify({
630
+ version: 1,
631
+ sourceUrl: options.sourceDirectory ? "local-directory" : `${new URL(options.sourceUrl).origin}${new URL(options.sourceUrl).pathname}`,
632
+ platform: bundle.platform,
633
+ pages: bundle.pages.length,
634
+ assets: bundle.assets.length,
635
+ pagesWritten,
636
+ assetsWritten,
637
+ components: bundle.componentFiles?.length ?? 0,
638
+ sourceRefs: bundle.sourceRefs ?? [],
639
+ quarantined: quarantinedPages,
640
+ quarantinedAssets: (bundle.quarantinedFiles?.length ?? 0) - quarantinedPages,
641
+ droppedGatedPages: bundle.droppedGatedPages ?? 0,
642
+ warnings: bundle.warnings,
643
+ conflicts,
644
+ removedSeedPaths,
645
+ ...existingPlan?.seedRelease ? { replacedSeedRelease: existingPlan.seedRelease } : {},
646
+ validation,
647
+ ...bundle.urlMigrationValidation ? { sourceValidation: bundle.urlMigrationValidation } : {}
648
+ }, null, 2)}
649
+ `);
650
+ for (const message of validation.messages) terminal.warn(message);
651
+ terminal.detail("Migration report", reportPath);
652
+ if (validation.content === "passed" && validation.build === "passed") {
653
+ terminal.success(`Content and production build passed.${bundle.warnings.length ? " Review the migration warnings for compatibility limitations." : ""}`);
654
+ } else {
655
+ terminal.warn("Import retained, but validation is incomplete. Do not publish without reviewing the report.");
656
+ }
657
+ if (!options.into) await initGit(projectDir);
658
+ return {
659
+ pagesWritten,
660
+ assetsWritten,
661
+ projectDir,
662
+ platform: bundle.platform,
663
+ warnings: bundle.warnings,
664
+ validation,
665
+ reportPath
666
+ };
667
+ }
668
+
669
+ export {
670
+ migrateDocs
671
+ };