@open-agent-toolkit/cli 0.3.15 → 0.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/NOTICES.md +48 -0
- package/assets/agents/oat-phase-implementer.md +26 -6
- package/assets/agents/oat-reviewer.md +51 -1
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/contributing/documentation.md +4 -0
- package/assets/docs/contributing/skills.md +3 -1
- package/assets/docs/getting-started/bootstrap.md +1 -1
- package/assets/docs/getting-started/index.md +0 -4
- package/assets/docs/getting-started/tool-packs.md +0 -7
- package/assets/docs/provider-sync/commands.md +21 -0
- package/assets/docs/reference/cli-reference.md +44 -2
- package/assets/docs/reference/config-and-local-state.md +11 -2
- package/assets/docs/reference/configuration.md +10 -2
- package/assets/docs/reference/project-artifacts.md +43 -8
- package/assets/docs/skills/index.md +1 -0
- package/assets/docs/skills/recon.md +8 -5
- package/assets/docs/skills/repo-improve.md +9 -7
- package/assets/docs/workflows/advanced/autonomy.md +50 -0
- package/assets/docs/workflows/advanced/workflow-gates.md +109 -10
- package/assets/docs/workflows/approvals-and-automation.md +3 -1
- package/assets/docs/workflows/backlog-and-planning/backlog-lifecycle.md +25 -24
- package/assets/docs/workflows/backlog-and-planning/remote-project-management.md +0 -7
- package/assets/docs/workflows/ideas/lifecycle.md +2 -7
- package/assets/docs/workflows/projects/closeout/closeout-skills.md +62 -1
- package/assets/docs/workflows/projects/execution/implementation-execution.md +23 -3
- package/assets/docs/workflows/projects/execution/project-log.md +6 -0
- package/assets/docs/workflows/projects/lifecycle.md +7 -0
- package/assets/docs/workflows/projects/reviews/index.md +63 -1
- package/assets/docs/workflows/projects/reviews/review-flavors.md +8 -0
- package/assets/docs/workflows/waves/wave-workflows.md +11 -3
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-agent-instructions-apply/SKILL.md +14 -4
- package/assets/skills/oat-brainstorm/SKILL.md +17 -13
- package/assets/skills/oat-docs-apply/SKILL.md +13 -3
- package/assets/skills/oat-doctor/SKILL.md +11 -1
- package/assets/skills/oat-explainer-kit/SKILL.md +1 -1
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +6 -3
- package/assets/skills/oat-pjm-review-backlog/SKILL.md +2 -2
- package/assets/skills/oat-pjm-update-repo-reference/SKILL.md +28 -3
- package/assets/skills/oat-project-autonomous/SKILL.md +60 -5
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +210 -165
- package/assets/skills/oat-project-capture/SKILL.md +7 -3
- package/assets/skills/oat-project-complete/SKILL.md +39 -34
- package/assets/skills/oat-project-complete/scripts/execute-synced-archive-entry.mjs +106 -28
- package/assets/skills/oat-project-complete/scripts/parse-synced-archive-resume-fields.mjs +30 -3
- package/assets/skills/oat-project-complete-auto/SKILL.md +388 -0
- package/assets/skills/oat-project-design/SKILL.md +12 -9
- package/assets/skills/oat-project-discover/SKILL.md +8 -4
- package/assets/skills/oat-project-document/SKILL.md +14 -5
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +210 -165
- package/assets/skills/oat-project-implement/SKILL.md +6 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +34 -7
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +210 -165
- package/assets/skills/oat-project-implement/references/docs/complexity-review-fallback.md +262 -0
- package/assets/skills/oat-project-implement/references/docs/gate-approval-record.md +80 -0
- package/assets/skills/oat-project-implement/references/phase-execution.md +38 -6
- package/assets/skills/oat-project-import-plan/SKILL.md +20 -6
- package/assets/skills/oat-project-lite/SKILL.md +7 -5
- package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +210 -165
- package/assets/skills/oat-project-new/SKILL.md +6 -2
- package/assets/skills/oat-project-next/SKILL.md +19 -2
- package/assets/skills/oat-project-next/references/docs/gate-approval-record.md +80 -0
- package/assets/skills/oat-project-plan/SKILL.md +14 -6
- package/assets/skills/oat-project-pr-final/SKILL.md +4 -3
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +210 -165
- package/assets/skills/oat-project-progress/SKILL.md +27 -1
- package/assets/skills/oat-project-progress/references/docs/gate-approval-record.md +80 -0
- package/assets/skills/oat-project-promote-spec-driven/SKILL.md +7 -3
- package/assets/skills/oat-project-quick-start/SKILL.md +47 -14
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +210 -165
- package/assets/skills/oat-project-quick-start/references/docs/complexity-review-fallback.md +262 -0
- package/assets/skills/oat-project-quick-start/references/docs/gate-approval-record.md +80 -0
- package/assets/skills/oat-project-reconcile/SKILL.md +8 -4
- package/assets/skills/oat-project-retro/SKILL.md +1 -1
- package/assets/skills/oat-project-retro/references/apply-procedure.md +6 -6
- package/assets/skills/oat-project-retro-file/SKILL.md +7 -3
- package/assets/skills/oat-project-review-provide/SKILL.md +23 -4
- package/assets/skills/oat-project-review-provide-remote/SKILL.md +19 -1
- package/assets/skills/oat-project-review-receive/SKILL.md +57 -14
- package/assets/skills/oat-project-review-receive/references/docs/complexity-review-fallback.md +262 -0
- package/assets/skills/oat-project-review-receive-remote/SKILL.md +7 -3
- package/assets/skills/oat-project-revise/SKILL.md +13 -5
- package/assets/skills/oat-project-spec/SKILL.md +8 -4
- package/assets/skills/oat-project-summary/SKILL.md +11 -7
- package/assets/skills/oat-repo-knowledge-index/SKILL.md +54 -20
- package/assets/skills/oat-repo-knowledge-index/scripts/refresh-owned.mjs +118 -0
- package/assets/skills/oat-review-provide/SKILL.md +7 -3
- package/assets/skills/oat-review-receive-remote/SKILL.md +14 -4
- package/assets/skills/oat-wave-execute/SKILL.md +72 -38
- package/assets/skills/oat-wave-execute/assets/wrapper-plan-template.md +16 -2
- package/assets/skills/oat-wave-program/SKILL.md +15 -6
- package/assets/skills/oat-worktree-bootstrap-auto/SKILL.md +45 -17
- package/assets/skills/oat-wrap-up/SKILL.md +9 -3
- package/assets/skills/oat-wrap-up/references/report-template.md +1 -1
- package/assets/skills/recon/SKILL.md +1 -1
- package/assets/skills/recon/references/packet-contract.md +18 -3
- package/assets/skills/recon/scripts/lib/contracts.mjs +16 -2
- package/assets/skills/recon/scripts/reconcile-ledger.mjs +20 -10
- package/assets/skills/recon/scripts/render-packet.mjs +31 -3
- package/assets/skills/recon/scripts/validate-packet.mjs +5 -5
- package/assets/templates/pjm-agents.md +38 -8
- package/assets/templates/repo-agents.md +5 -0
- package/assets/templates/repo-readme.md +5 -0
- package/assets/templates/state.md +7 -0
- package/dist/commands/backlog/archive.d.ts +3 -0
- package/dist/commands/backlog/archive.d.ts.map +1 -1
- package/dist/commands/backlog/archive.js +63 -26
- package/dist/commands/backlog/index.d.ts.map +1 -1
- package/dist/commands/backlog/index.js +3 -0
- package/dist/commands/backlog/rewrite-references.d.ts +5 -0
- package/dist/commands/backlog/rewrite-references.d.ts.map +1 -1
- package/dist/commands/backlog/rewrite-references.js +39 -1
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +13 -0
- package/dist/commands/gate/index.d.ts +5 -0
- package/dist/commands/gate/index.d.ts.map +1 -1
- package/dist/commands/gate/index.js +270 -9
- package/dist/commands/init/index.d.ts.map +1 -1
- package/dist/commands/init/index.js +1 -0
- package/dist/commands/internal/commit-paths.d.ts +3 -0
- package/dist/commands/internal/commit-paths.d.ts.map +1 -0
- package/dist/commands/internal/commit-paths.js +31 -0
- package/dist/commands/internal/index.d.ts.map +1 -1
- package/dist/commands/internal/index.js +2 -0
- package/dist/commands/pjm/init.d.ts.map +1 -1
- package/dist/commands/pjm/init.js +37 -8
- package/dist/commands/project/archive/archive-utils.d.ts +6 -3
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +510 -104
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +1 -0
- package/dist/commands/project/log/append.d.ts +2 -3
- package/dist/commands/project/log/append.d.ts.map +1 -1
- package/dist/commands/project/log/append.js +55 -101
- package/dist/commands/project/migrate/index.d.ts +1 -2
- package/dist/commands/project/migrate/index.d.ts.map +1 -1
- package/dist/commands/project/migrate/index.js +8 -2
- package/dist/commands/project/new/scaffold.d.ts.map +1 -1
- package/dist/commands/project/new/scaffold.js +4 -4
- package/dist/commands/project/promote/promote.d.ts +8 -0
- package/dist/commands/project/promote/promote.d.ts.map +1 -1
- package/dist/commands/project/promote/promote.js +37 -6
- package/dist/commands/project/status.d.ts.map +1 -1
- package/dist/commands/project/status.js +3 -0
- package/dist/commands/project/sync/ref-sync.d.ts +12 -1
- package/dist/commands/project/sync/ref-sync.d.ts.map +1 -1
- package/dist/commands/project/sync/ref-sync.js +347 -23
- package/dist/commands/shared/exact-path-commit.d.ts +32 -0
- package/dist/commands/shared/exact-path-commit.d.ts.map +1 -0
- package/dist/commands/shared/exact-path-commit.js +691 -0
- package/dist/commands/shared/frontmatter.d.ts +1 -1
- package/dist/commands/shared/frontmatter.d.ts.map +1 -1
- package/dist/commands/shared/frontmatter.js +1 -0
- package/dist/commands/state/generate.d.ts +1 -1
- package/dist/commands/state/generate.d.ts.map +1 -1
- package/dist/commands/state/generate.js +87 -8
- package/dist/commands/sync/apply.d.ts.map +1 -1
- package/dist/commands/sync/apply.js +31 -5
- package/dist/commands/sync/dry-run.d.ts.map +1 -1
- package/dist/commands/sync/dry-run.js +28 -11
- package/dist/commands/sync/sync.utils.d.ts +13 -0
- package/dist/commands/sync/sync.utils.d.ts.map +1 -1
- package/dist/commands/sync/sync.utils.js +21 -1
- package/dist/commands/tools/shared/pack-manifest.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-manifest.js +1 -0
- package/dist/config/oat-config.d.ts +1 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +3 -0
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +1 -0
- package/dist/drift/detector.js +6 -6
- package/dist/engine/compute-plan.d.ts.map +1 -1
- package/dist/engine/compute-plan.js +87 -2
- package/dist/engine/engine.types.d.ts +10 -1
- package/dist/engine/engine.types.d.ts.map +1 -1
- package/dist/engine/engine.types.js +4 -0
- package/dist/engine/execute-plan.d.ts.map +1 -1
- package/dist/engine/execute-plan.js +42 -5
- package/dist/engine/managed-copy-hash.d.ts +4 -3
- package/dist/engine/managed-copy-hash.d.ts.map +1 -1
- package/dist/engine/managed-copy-hash.js +4 -3
- package/dist/fs/assets.d.ts.map +1 -1
- package/dist/fs/assets.js +7 -1
- package/dist/manifest/hash.d.ts +8 -10
- package/dist/manifest/hash.d.ts.map +1 -1
- package/dist/ui/output.js +1 -1
- package/dist/validation/skills.d.ts.map +1 -1
- package/dist/validation/skills.js +151 -11
- package/package.json +3 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { lstat, readFile, readdir, realpath, rename, rm, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { link, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile, } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, isAbsolute, join, posix as path, relative, resolve, sep, } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { defaultGitRunner } from '../../project/sync/git.js';
|
|
@@ -10,6 +10,7 @@ import { probeSyncedTerminalRefs } from '../../project/sync/resolve-target.js';
|
|
|
10
10
|
import { canonicalizePath, resolveProjectScope, resolveScopeRoot, syncedRecordPath, } from '../../shared/project-scope.js';
|
|
11
11
|
import { CliError } from '../../../errors/cli-error.js';
|
|
12
12
|
import { copyDirectory, copySingleFile, dirExists, ensureDir, fileExists, } from '../../../fs/io.js';
|
|
13
|
+
import { decodeHTMLAttribute } from 'entities/decode';
|
|
13
14
|
import { loadExplainerPackageCoverage } from './explainer-package-coverage.js';
|
|
14
15
|
const execFileAsync = promisify(execFileCallback);
|
|
15
16
|
export const ARCHIVE_SNAPSHOT_METADATA_FILENAME = '.oat-archive-source.json';
|
|
@@ -415,7 +416,7 @@ async function resolveRecordlessSyncedArchiveIdentity(options, target, git, depe
|
|
|
415
416
|
}
|
|
416
417
|
return matches[0];
|
|
417
418
|
}
|
|
418
|
-
async function exportProjectSummary(archivePath, snapshotName, summaryExportPath, repoRoot, dependencies) {
|
|
419
|
+
async function exportProjectSummary(archivePath, snapshotName, summaryExportPath, repoRoot, dependencies, recapExport) {
|
|
419
420
|
const summarySource = join(archivePath, 'summary.md');
|
|
420
421
|
const exists = dependencies.fileExists ?? fileExists;
|
|
421
422
|
if (!(await exists(summarySource))) {
|
|
@@ -423,17 +424,35 @@ async function exportProjectSummary(archivePath, snapshotName, summaryExportPath
|
|
|
423
424
|
}
|
|
424
425
|
const summaryTarget = join(repoRoot, summaryExportPath, `${snapshotName}.md`);
|
|
425
426
|
const copySummary = dependencies.copySingleFile ?? copySingleFile;
|
|
427
|
+
const sourceContents = await readFile(summarySource);
|
|
428
|
+
let expectedContents = sourceContents;
|
|
429
|
+
if (recapExport) {
|
|
430
|
+
const pageLink = relative(dirname(summaryTarget), recapExport.exportRoot)
|
|
431
|
+
.split(sep)
|
|
432
|
+
.join('/');
|
|
433
|
+
const receipt = `Recap: [View the recap](${pageLink})\n\nRun: \`${recapExport.runId}\` \nOriginal page SHA-256: \`${recapExport.page.originalSha256}\` \nExported page SHA-256: \`${recapExport.page.exportedSha256}\`\n`;
|
|
434
|
+
const text = sourceContents.toString('utf8');
|
|
435
|
+
const outcome = /(^## Explainer Outcome[^\n]*\n)([\s\S]*?)(?=^## |$(?![\s\S]))/m;
|
|
436
|
+
const rewriteLinks = (body) => body.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (markdownLink, label, href) => /(?:explainers\/|project-recaps\/)/.test(href)
|
|
437
|
+
? `[${label}](${pageLink})`
|
|
438
|
+
: markdownLink);
|
|
439
|
+
expectedContents = Buffer.from(outcome.test(text)
|
|
440
|
+
? text.replace(outcome, (_match, heading, body) => `${heading}${rewriteLinks(body).trimEnd()}\n\n${receipt}\n`)
|
|
441
|
+
: `${text.trimEnd()}\n\n## Explainer Outcome\n\n${receipt}`);
|
|
442
|
+
}
|
|
426
443
|
if (await pathExists(summaryTarget)) {
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
readFile(summaryTarget),
|
|
430
|
-
]);
|
|
431
|
-
if (!sourceContents.equals(targetContents)) {
|
|
444
|
+
const targetContents = await readFile(summaryTarget);
|
|
445
|
+
if (!expectedContents.equals(targetContents)) {
|
|
432
446
|
throw new CliError(`Existing summary export \`${summaryTarget}\` does not match persisted snapshot \`${snapshotName}\`; refusing to overwrite it.`);
|
|
433
447
|
}
|
|
434
448
|
return summaryTarget;
|
|
435
449
|
}
|
|
436
450
|
await copySummary(summarySource, summaryTarget);
|
|
451
|
+
if (recapExport)
|
|
452
|
+
await writeFile(summaryTarget, expectedContents);
|
|
453
|
+
if (!expectedContents.equals(await readFile(summaryTarget))) {
|
|
454
|
+
throw new CliError(`Summary export \`${summaryTarget}\` failed byte verification.`);
|
|
455
|
+
}
|
|
437
456
|
return summaryTarget;
|
|
438
457
|
}
|
|
439
458
|
async function parseProjectRecapManifest(contents) {
|
|
@@ -444,11 +463,48 @@ async function parseProjectRecapManifest(contents) {
|
|
|
444
463
|
catch {
|
|
445
464
|
throw new CliError('Selected project recap has an invalid manifest.json.');
|
|
446
465
|
}
|
|
447
|
-
if (!isProjectRecapManifestV2(value)
|
|
466
|
+
if (!isProjectRecapManifestV2(value) &&
|
|
467
|
+
!isLegacyProjectRecapManifest(value)) {
|
|
448
468
|
throw new CliError('Selected project recap manifest does not match the explainer-kit manifest contract.');
|
|
449
469
|
}
|
|
450
470
|
return value;
|
|
451
471
|
}
|
|
472
|
+
// Compatibility is derived from the captured July v1 packages. Their manifest
|
|
473
|
+
// and build record receive durability attestations after immutable recording.
|
|
474
|
+
function isLegacyProjectRecapManifest(value) {
|
|
475
|
+
if (!isRecord(value) ||
|
|
476
|
+
value.schemaVersion !== 'explainer-kit.manifest/v1' ||
|
|
477
|
+
!isNonEmptyString(value.runId) ||
|
|
478
|
+
!isNonEmptyString(value.slug) ||
|
|
479
|
+
!isDateTime(value.createdAt) ||
|
|
480
|
+
!isRecord(value.recipe) ||
|
|
481
|
+
value.recipe.id !== 'project-recap' ||
|
|
482
|
+
!isRecord(value.source) ||
|
|
483
|
+
!isSafeRelativePath(value.source.factBasePath) ||
|
|
484
|
+
!isSha256(value.source.factBaseHash) ||
|
|
485
|
+
!isHashMap(value.source.inputHashes) ||
|
|
486
|
+
!isRecord(value.theme) ||
|
|
487
|
+
value.theme.path !== 'theme.resolved.json' ||
|
|
488
|
+
!isSha256(value.theme.hash) ||
|
|
489
|
+
!isRecord(value.buildRecord) ||
|
|
490
|
+
value.buildRecord.path !== 'build-record.json' ||
|
|
491
|
+
!isSha256(value.buildRecord.hash) ||
|
|
492
|
+
!isHashMap(value.immutableHashes) ||
|
|
493
|
+
!Array.isArray(value.source.authorResultPaths) ||
|
|
494
|
+
!value.source.authorResultPaths.every(isSafeRelativePath) ||
|
|
495
|
+
!Array.isArray(value.artifacts) ||
|
|
496
|
+
value.artifacts.length !== 1 ||
|
|
497
|
+
!['built', 'built-needs-review', 'built-durable'].includes(String(value.outcome)))
|
|
498
|
+
return false;
|
|
499
|
+
return value.artifacts.every((artifact) => isRecord(artifact) &&
|
|
500
|
+
isNonEmptyString(artifact.id) &&
|
|
501
|
+
artifact.status === 'built' &&
|
|
502
|
+
isSafeRelativePath(artifact.contentPath) &&
|
|
503
|
+
isSafeRelativePath(artifact.renderedPath) &&
|
|
504
|
+
artifact.renderedPath.startsWith('site/') &&
|
|
505
|
+
artifact.mediaType === 'text/html' &&
|
|
506
|
+
isSha256(artifact.hash));
|
|
507
|
+
}
|
|
452
508
|
function isProjectRecapManifestV2(value) {
|
|
453
509
|
if (!isRecord(value) ||
|
|
454
510
|
!hasExactKeys(value, [
|
|
@@ -603,6 +659,34 @@ async function verifyProjectRecapImmutableHashes(stagedRoot, manifest) {
|
|
|
603
659
|
}
|
|
604
660
|
return entries.length;
|
|
605
661
|
}
|
|
662
|
+
async function verifyRecapInventory(root, files) {
|
|
663
|
+
const allowed = new Set(files);
|
|
664
|
+
const directories = new Set();
|
|
665
|
+
for (const file of files) {
|
|
666
|
+
let parent = dirname(file);
|
|
667
|
+
while (parent !== '.') {
|
|
668
|
+
directories.add(parent);
|
|
669
|
+
parent = dirname(parent);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const present = new Set();
|
|
673
|
+
async function inspect(current = '') {
|
|
674
|
+
for (const entry of await readdir(join(root, current), {
|
|
675
|
+
withFileTypes: true,
|
|
676
|
+
})) {
|
|
677
|
+
const entryPath = current ? `${current}/${entry.name}` : entry.name;
|
|
678
|
+
if (entry.isDirectory() && directories.has(entryPath))
|
|
679
|
+
await inspect(entryPath);
|
|
680
|
+
else if (entry.isFile() && allowed.has(entryPath))
|
|
681
|
+
present.add(entryPath);
|
|
682
|
+
else
|
|
683
|
+
throw new CliError('Selected project recap package inventory is invalid.');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
await inspect();
|
|
687
|
+
if (files.some((file) => !present.has(file)))
|
|
688
|
+
throw new CliError('Selected project recap package inventory is invalid.');
|
|
689
|
+
}
|
|
606
690
|
async function loadVerifiedProjectRecap(projectPath, projectRecapRun) {
|
|
607
691
|
const sourceRunRoot = await resolveSelectedProjectRecapRun(projectPath, projectRecapRun);
|
|
608
692
|
const manifestContents = await readFile(join(sourceRunRoot, 'manifest.json'), 'utf8');
|
|
@@ -610,11 +694,69 @@ async function loadVerifiedProjectRecap(projectPath, projectRecapRun) {
|
|
|
610
694
|
if (manifest.recipe.id !== 'project-recap') {
|
|
611
695
|
throw new CliError('Selected project recap manifest recipe must be exactly `project-recap`.');
|
|
612
696
|
}
|
|
613
|
-
if (![
|
|
697
|
+
if (![
|
|
698
|
+
'built',
|
|
699
|
+
'built-needs-review',
|
|
700
|
+
...(manifest.schemaVersion === 'explainer-kit.manifest/v1'
|
|
701
|
+
? ['built-durable']
|
|
702
|
+
: []),
|
|
703
|
+
].includes(manifest.outcome) ||
|
|
614
704
|
manifest.artifacts.some((artifact) => artifact.status !== 'built')) {
|
|
615
705
|
throw new CliError('Selected project recap must have a satisfied outcome and built artifacts.');
|
|
616
706
|
}
|
|
617
707
|
const verifiedArtifactCount = await verifyProjectRecapImmutableHashes(sourceRunRoot, manifest);
|
|
708
|
+
if (manifest.schemaVersion === 'explainer-kit.manifest/v1') {
|
|
709
|
+
const raw = JSON.parse(manifestContents);
|
|
710
|
+
// July v1 hashes fact-base/theme canonical JSON identities separately
|
|
711
|
+
// from immutableHashes' exact file-byte digests (captured writer contract).
|
|
712
|
+
const canonicalize = (value) => {
|
|
713
|
+
if (Array.isArray(value))
|
|
714
|
+
return value.map(canonicalize);
|
|
715
|
+
if (isRecord(value))
|
|
716
|
+
return Object.fromEntries(Object.keys(value)
|
|
717
|
+
.sort()
|
|
718
|
+
.map((key) => [key, canonicalize(value[key])]));
|
|
719
|
+
return value;
|
|
720
|
+
};
|
|
721
|
+
const canonicalFileHash = async (file) => sha256(JSON.stringify(canonicalize(JSON.parse(await readFile(join(sourceRunRoot, file), 'utf8')))));
|
|
722
|
+
const required = [
|
|
723
|
+
'run-request.json',
|
|
724
|
+
'source/fact-base.json',
|
|
725
|
+
'source/fact-base.md',
|
|
726
|
+
'source/content-approval.json',
|
|
727
|
+
'theme.resolved.json',
|
|
728
|
+
...raw.source.authorResultPaths,
|
|
729
|
+
...manifest.artifacts.flatMap((artifact) => [
|
|
730
|
+
artifact.contentPath,
|
|
731
|
+
artifact.renderedPath,
|
|
732
|
+
]),
|
|
733
|
+
];
|
|
734
|
+
if (required.some((file) => !(file in manifest.immutableHashes)) ||
|
|
735
|
+
manifest.source.factBaseHash !==
|
|
736
|
+
(await canonicalFileHash(manifest.source.factBasePath)) ||
|
|
737
|
+
manifest.theme.hash !== (await canonicalFileHash(manifest.theme.path)) ||
|
|
738
|
+
manifest.artifacts.some((artifact) => artifact.hash !== manifest.immutableHashes[artifact.renderedPath])) {
|
|
739
|
+
throw new CliError('Selected legacy recap has incomplete immutable package hashes.');
|
|
740
|
+
}
|
|
741
|
+
const packagePaths = [
|
|
742
|
+
...Object.keys(manifest.immutableHashes),
|
|
743
|
+
'manifest.json',
|
|
744
|
+
'build-record.json',
|
|
745
|
+
].sort();
|
|
746
|
+
await verifyRecapInventory(sourceRunRoot, packagePaths);
|
|
747
|
+
const record = JSON.parse(await readFile(join(sourceRunRoot, 'build-record.json'), 'utf8'));
|
|
748
|
+
if (record.schemaVersion !== 'explainer-kit.build-record/v1' ||
|
|
749
|
+
record.runId !== manifest.runId ||
|
|
750
|
+
record.outcome !== manifest.outcome)
|
|
751
|
+
throw new CliError('Legacy recap build record identity does not match its manifest.');
|
|
752
|
+
return {
|
|
753
|
+
sourceRunRoot,
|
|
754
|
+
manifestContents,
|
|
755
|
+
manifest,
|
|
756
|
+
verifiedArtifactCount,
|
|
757
|
+
packagePaths,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
618
760
|
const packageCoverage = await loadExplainerPackageCoverage();
|
|
619
761
|
const missingCoverage = packageCoverage
|
|
620
762
|
.requiredImmutablePackagePaths(manifest)
|
|
@@ -656,88 +798,353 @@ async function loadVerifiedProjectRecap(projectPath, projectRecapRun) {
|
|
|
656
798
|
export async function verifySelectedProjectRecapForArchive(projectPath, projectRecapRun) {
|
|
657
799
|
await loadVerifiedProjectRecap(projectPath, projectRecapRun);
|
|
658
800
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
801
|
+
function sha256(bytes) {
|
|
802
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
803
|
+
}
|
|
804
|
+
async function transformRecapPage(html, sourcePage, sourceRunRoot, stagedRoot, exportPage, options) {
|
|
805
|
+
const external = (url) => /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url);
|
|
806
|
+
const resolveAssetPath = async (url, base) => {
|
|
807
|
+
const assetPath = resolve(dirname(base), decodeURIComponent(url.split(/[?#]/)[0]));
|
|
808
|
+
if (!isInsidePath(sourceRunRoot, assetPath) ||
|
|
809
|
+
!isInsidePath(await realpath(sourceRunRoot), await realpath(assetPath)))
|
|
810
|
+
throw new CliError(`Recap asset escapes its verified package: ${url}`);
|
|
811
|
+
return assetPath;
|
|
812
|
+
};
|
|
813
|
+
const asset = async (url, base) => {
|
|
814
|
+
if (external(url) || url.startsWith('#'))
|
|
815
|
+
return url;
|
|
816
|
+
const assetPath = await resolveAssetPath(url, base);
|
|
817
|
+
const bytes = await readFile(join(stagedRoot, relative(sourceRunRoot, assetPath)));
|
|
818
|
+
const mime = {
|
|
819
|
+
png: 'image/png',
|
|
820
|
+
svg: 'image/svg+xml',
|
|
821
|
+
jpg: 'image/jpeg',
|
|
822
|
+
jpeg: 'image/jpeg',
|
|
823
|
+
gif: 'image/gif',
|
|
824
|
+
webp: 'image/webp',
|
|
825
|
+
woff: 'font/woff',
|
|
826
|
+
woff2: 'font/woff2',
|
|
827
|
+
};
|
|
828
|
+
const type = mime[assetPath.split('.').pop() ?? ''] ?? 'application/octet-stream';
|
|
829
|
+
return `data:${type};base64,${bytes.toString('base64')}`;
|
|
830
|
+
};
|
|
831
|
+
const replaceAsync = async (text, pattern, callback) => {
|
|
832
|
+
const matches = [...text.matchAll(pattern)];
|
|
833
|
+
const replacements = await Promise.all(matches.map(callback));
|
|
834
|
+
for (let index = matches.length - 1; index >= 0; index--) {
|
|
835
|
+
const match = matches[index];
|
|
836
|
+
text =
|
|
837
|
+
text.slice(0, match.index) +
|
|
838
|
+
replacements[index] +
|
|
839
|
+
text.slice(match.index + match[0].length);
|
|
840
|
+
}
|
|
841
|
+
return text;
|
|
842
|
+
};
|
|
843
|
+
const decodeCssUrl = (url) => url.replace(/\\(?:([0-9a-f]{1,6})(?:\r\n|[ \t\n\r\f])?|\r\n|[\n\r\f]|([\s\S]))/gi, (_match, hex, escaped) => {
|
|
844
|
+
if (hex === undefined)
|
|
845
|
+
return escaped ?? '';
|
|
846
|
+
const codePoint = Number.parseInt(hex, 16);
|
|
847
|
+
return String.fromCodePoint(codePoint === 0 ||
|
|
848
|
+
codePoint > 0x10ffff ||
|
|
849
|
+
(codePoint >= 0xd800 && codePoint <= 0xdfff)
|
|
850
|
+
? 0xfffd
|
|
851
|
+
: codePoint);
|
|
852
|
+
});
|
|
853
|
+
const quoteCssUrl = (url) => `"${url.replace(/["\\<\p{Cc}]/gu, (character) => character === '"' || character === '\\'
|
|
854
|
+
? `\\${character}`
|
|
855
|
+
: `\\${character.charCodeAt(0).toString(16)} `)}"`;
|
|
856
|
+
const css = async (text, base) => replaceAsync(text,
|
|
857
|
+
// Consume CSS comments/strings before recognizing a resource URL. Text in
|
|
858
|
+
// those bodies can describe markup or url(...) without referencing an asset.
|
|
859
|
+
/\/\*[\s\S]*?(?:\*\/|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|(?<![\w-])url\(\s*(?:"((?:\\[\s\S]|[^"\\])+)"|'((?:\\[\s\S]|[^'\\])+)'|((?:\\[\s\S]|[^)'"\\\s])+))\s*\)/gi, async (match) => {
|
|
860
|
+
const url = match[1] ?? match[2] ?? match[3];
|
|
861
|
+
return url === undefined
|
|
862
|
+
? match[0]
|
|
863
|
+
: `url(${quoteCssUrl(await asset(decodeCssUrl(url), base))})`;
|
|
864
|
+
});
|
|
865
|
+
// Match complete attributes, including unrelated quoted values. Looking only
|
|
866
|
+
// for href/src would also find those strings inside title/data attributes.
|
|
867
|
+
const attributePattern = /\s+([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+)))?/g;
|
|
868
|
+
const attributeValue = (match) => {
|
|
869
|
+
const value = match[2] ?? match[3] ?? match[4];
|
|
870
|
+
return value === undefined ? undefined : decodeHTMLAttribute(value);
|
|
871
|
+
};
|
|
872
|
+
const emitAttribute = (name, value, quote = '"') => ` ${name}=${quote}${value
|
|
873
|
+
.replaceAll('&', '&')
|
|
874
|
+
.replaceAll(quote, quote === '"' ? '"' : ''')}${quote}`;
|
|
875
|
+
const attributes = (tag) => new Map([...tag.matchAll(attributePattern)].map((match) => [
|
|
876
|
+
match[1].toLowerCase(),
|
|
877
|
+
attributeValue(match),
|
|
878
|
+
]));
|
|
879
|
+
const markup = /<!--[\s\S]*?(?:-->|$)|<![^>]*>|<\?[^>]*>|<[a-z][a-z0-9:-]*(?=[\s/>])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
|
|
880
|
+
const rawText = /^(script|style|textarea|title|xmp|iframe|noembed|noframes|plaintext)$/;
|
|
881
|
+
const fragmentTargets = new Set();
|
|
882
|
+
let targetMatch;
|
|
883
|
+
// Use the same markup/attribute readers for forward targets. Descriptions of
|
|
884
|
+
// markup inside comments, attribute values, and raw-text bodies are not targets.
|
|
885
|
+
while ((targetMatch = markup.exec(html))) {
|
|
886
|
+
const name = /^<([a-z][a-z0-9:-]*)/i
|
|
887
|
+
.exec(targetMatch[0])?.[1]
|
|
888
|
+
?.toLowerCase();
|
|
889
|
+
if (!name)
|
|
890
|
+
continue;
|
|
891
|
+
const values = attributes(targetMatch[0]);
|
|
892
|
+
for (const attribute of ['id', 'name']) {
|
|
893
|
+
const value = values.get(attribute);
|
|
894
|
+
if (value !== undefined)
|
|
895
|
+
fragmentTargets.add(value);
|
|
896
|
+
}
|
|
897
|
+
if (rawText.test(name)) {
|
|
898
|
+
const closing = new RegExp(`</${name}\\s*>`, 'gi');
|
|
899
|
+
closing.lastIndex = markup.lastIndex;
|
|
900
|
+
const end = name === 'plaintext' ? null : closing.exec(html);
|
|
901
|
+
markup.lastIndex = end ? closing.lastIndex : html.length;
|
|
902
|
+
}
|
|
663
903
|
}
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
904
|
+
const rewriteAttributes = async (tag, removeScriptSource = false) => replaceAsync(tag, attributePattern, async (match) => {
|
|
905
|
+
const name = match[1].toLowerCase();
|
|
906
|
+
const url = attributeValue(match);
|
|
907
|
+
if (url === undefined)
|
|
908
|
+
return match[0];
|
|
909
|
+
if (name === 'style') {
|
|
910
|
+
const value = await css(url, sourcePage);
|
|
911
|
+
if (value === url)
|
|
912
|
+
return match[0];
|
|
913
|
+
const quote = match[3] !== undefined ? "'" : '"';
|
|
914
|
+
return emitAttribute(match[1], value, quote);
|
|
671
915
|
}
|
|
672
|
-
|
|
673
|
-
|
|
916
|
+
if (name !== 'href' && name !== 'src')
|
|
917
|
+
return match[0];
|
|
918
|
+
if (name === 'src' && removeScriptSource)
|
|
919
|
+
return '';
|
|
920
|
+
if (external(url))
|
|
921
|
+
return match[0];
|
|
922
|
+
if (name === 'src') {
|
|
923
|
+
const quote = match[3] !== undefined ? "'" : '"';
|
|
924
|
+
return emitAttribute('src', await asset(url, sourcePage), quote);
|
|
925
|
+
}
|
|
926
|
+
const [pathname, fragment] = url.split('#');
|
|
927
|
+
if (!pathname) {
|
|
928
|
+
if (fragment && !fragmentTargets.has(fragment))
|
|
929
|
+
return '';
|
|
930
|
+
return match[0];
|
|
931
|
+
}
|
|
932
|
+
const original = resolve(dirname(sourcePage), decodeURIComponent(pathname.split('?')[0]));
|
|
933
|
+
if (original === sourcePage) {
|
|
934
|
+
if (!fragment || !fragmentTargets.has(fragment))
|
|
935
|
+
return '';
|
|
936
|
+
return emitAttribute('href', `#${fragment}`);
|
|
937
|
+
}
|
|
938
|
+
let target = original;
|
|
939
|
+
if (original === resolve(options.projectPath, 'summary.md') &&
|
|
940
|
+
options.summaryExportPath)
|
|
941
|
+
target = resolve(options.repoRoot, options.summaryExportPath, `${basename(exportPage, '.html')}.md`);
|
|
942
|
+
const referenceRoot = resolve(options.repoRoot, '.oat/repo/reference');
|
|
943
|
+
const preserved = isInsidePath(referenceRoot, target) &&
|
|
944
|
+
!isInsidePath(resolve(referenceRoot, 'project-recaps'), target) &&
|
|
945
|
+
(await fileExists(target));
|
|
946
|
+
// This provisional link is removed from attempt-owned output if completion
|
|
947
|
+
// cannot verify the summary export; an adopted page is never rewritten.
|
|
948
|
+
const pendingSummary = original === resolve(options.projectPath, 'summary.md') &&
|
|
949
|
+
Boolean(options.summaryExportPath) &&
|
|
950
|
+
(await fileExists(original));
|
|
951
|
+
if (!preserved && !pendingSummary)
|
|
952
|
+
return '';
|
|
953
|
+
if (fragment) {
|
|
954
|
+
const contents = await readFile(preserved ? target : original, 'utf8');
|
|
955
|
+
const anchors = [...contents.matchAll(/^#+\s+(.+)$/gm)].map((heading) => heading[1]
|
|
956
|
+
.toLowerCase()
|
|
957
|
+
.replace(/[^\w\s-]/g, '')
|
|
958
|
+
.trim()
|
|
959
|
+
.replace(/\s+/g, '-'));
|
|
960
|
+
if (!anchors.includes(fragment) &&
|
|
961
|
+
!contents.includes(`id="${fragment}"`))
|
|
962
|
+
return '';
|
|
674
963
|
}
|
|
675
|
-
|
|
676
|
-
|
|
964
|
+
return emitAttribute('href', `${relative(dirname(exportPage), target).split(sep).join('/')}${fragment ? `#${fragment}` : ''}`);
|
|
965
|
+
});
|
|
966
|
+
// Walk markup once. Comments and raw-text elements are opaque to attribute
|
|
967
|
+
// rewriting; generated script/style bodies are never fed back into the walk.
|
|
968
|
+
let output = '';
|
|
969
|
+
let cursor = 0;
|
|
970
|
+
let match;
|
|
971
|
+
markup.lastIndex = 0;
|
|
972
|
+
while ((match = markup.exec(html))) {
|
|
973
|
+
output += html.slice(cursor, match.index);
|
|
974
|
+
const tag = match[0];
|
|
975
|
+
const name = /^<([a-z][a-z0-9:-]*)/i.exec(tag)?.[1]?.toLowerCase();
|
|
976
|
+
cursor = markup.lastIndex;
|
|
977
|
+
if (!name) {
|
|
978
|
+
output += tag;
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
const values = attributes(tag);
|
|
982
|
+
if (name === 'link' && values.get('rel')?.toLowerCase() === 'stylesheet') {
|
|
983
|
+
const url = values.get('href');
|
|
984
|
+
if (url !== undefined && !external(url)) {
|
|
985
|
+
const file = await resolveAssetPath(url, sourcePage);
|
|
986
|
+
output += `<style>${await css(await readFile(join(stagedRoot, relative(sourceRunRoot, file)), 'utf8'), file)}</style>`;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (rawText.test(name)) {
|
|
991
|
+
const closing = new RegExp(`</${name}\\s*>`, 'gi');
|
|
992
|
+
closing.lastIndex = cursor;
|
|
993
|
+
const end = name === 'plaintext' ? null : closing.exec(html);
|
|
994
|
+
let body = html.slice(cursor, end?.index ?? html.length);
|
|
995
|
+
let inlineScript = false;
|
|
996
|
+
const url = values.get('src');
|
|
997
|
+
if (name === 'script' &&
|
|
998
|
+
end &&
|
|
999
|
+
!body.trim() &&
|
|
1000
|
+
url !== undefined &&
|
|
1001
|
+
!external(url)) {
|
|
1002
|
+
const file = await resolveAssetPath(url, sourcePage);
|
|
1003
|
+
body = (await readFile(join(stagedRoot, relative(sourceRunRoot, file)), 'utf8')).replace(/<\/script/gi, '<\\/script');
|
|
1004
|
+
inlineScript = true;
|
|
1005
|
+
}
|
|
1006
|
+
else if (name === 'style') {
|
|
1007
|
+
body = await css(body, sourcePage);
|
|
1008
|
+
}
|
|
1009
|
+
output +=
|
|
1010
|
+
(await rewriteAttributes(tag, inlineScript)) + body + (end?.[0] ?? '');
|
|
1011
|
+
cursor = end ? closing.lastIndex : html.length;
|
|
1012
|
+
markup.lastIndex = cursor;
|
|
677
1013
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1014
|
+
else {
|
|
1015
|
+
output += await rewriteAttributes(tag);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return output + html.slice(cursor);
|
|
1019
|
+
}
|
|
1020
|
+
async function cleanupOwnedRecapPath(target, identity, removePath, renamePath, expectedHash) {
|
|
1021
|
+
if (!(await pathExists(target)))
|
|
1022
|
+
return;
|
|
1023
|
+
// Claim into a private, exclusively created namespace before inspecting bytes.
|
|
1024
|
+
// Other writers retain the original public pathname; deletion uses only our claim.
|
|
1025
|
+
const claimRoot = await mkdtemp(join(dirname(target), '.recap-cleanup-claim-'));
|
|
1026
|
+
const claimedPath = join(claimRoot, basename(target));
|
|
1027
|
+
try {
|
|
1028
|
+
await renamePath(target, claimedPath);
|
|
1029
|
+
}
|
|
1030
|
+
catch (error) {
|
|
1031
|
+
await rm(claimRoot, { recursive: true, force: true });
|
|
1032
|
+
if (isRecord(error) && error.code === 'ENOENT')
|
|
1033
|
+
return;
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
const stat = await lstat(claimedPath);
|
|
1037
|
+
const owned = stat.dev === identity.dev &&
|
|
1038
|
+
stat.ino === identity.ino &&
|
|
1039
|
+
stat.birthtimeMs === identity.birthtimeMs &&
|
|
1040
|
+
(expectedHash === undefined
|
|
1041
|
+
? stat.isDirectory()
|
|
1042
|
+
: stat.isFile() && sha256(await readFile(claimedPath)) === expectedHash);
|
|
1043
|
+
if (owned) {
|
|
1044
|
+
await removePath(claimedPath, { recursive: true, force: true });
|
|
1045
|
+
await rm(claimRoot, { recursive: true, force: true });
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (stat.isFile()) {
|
|
681
1049
|
try {
|
|
682
|
-
|
|
1050
|
+
// link is an atomic no-clobber restoration, even if a third writer arrives.
|
|
1051
|
+
await link(claimedPath, target);
|
|
683
1052
|
}
|
|
684
|
-
catch {
|
|
685
|
-
throw new CliError(`
|
|
1053
|
+
catch (error) {
|
|
1054
|
+
throw new CliError(`Recap cleanup preserved foreign content at ${claimedPath}; refusing to overwrite ${target}: ${error instanceof Error ? error.message : String(error)}`);
|
|
686
1055
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
exportRoot,
|
|
691
|
-
manifest: {
|
|
692
|
-
relativePath: 'manifest.json',
|
|
693
|
-
verifiedArtifactCount,
|
|
694
|
-
},
|
|
695
|
-
},
|
|
696
|
-
createdByAttempt: false,
|
|
697
|
-
};
|
|
1056
|
+
await rm(claimedPath);
|
|
1057
|
+
await rm(claimRoot, { recursive: true, force: true });
|
|
1058
|
+
return;
|
|
698
1059
|
}
|
|
1060
|
+
// Node has no portable atomic no-clobber rename for a directory. Keep its
|
|
1061
|
+
// bytes in the diagnostic claim rather than overwriting any new public path.
|
|
1062
|
+
throw new CliError(`Recap cleanup preserved a foreign replacement at ${claimedPath}; safe no-clobber directory restoration is unavailable for ${target}.`);
|
|
1063
|
+
}
|
|
1064
|
+
async function removeAttemptRecapExport(attempt, removePath, renamePath) {
|
|
1065
|
+
if (!attempt?.createdByAttempt || !attempt.identity)
|
|
1066
|
+
return;
|
|
1067
|
+
await cleanupOwnedRecapPath(attempt.export.exportRoot, attempt.identity, removePath, renamePath, attempt.export.page.exportedSha256);
|
|
1068
|
+
}
|
|
1069
|
+
async function exportSelectedProjectRecap(options, snapshotName, dependencies) {
|
|
1070
|
+
const selectedRun = options.projectRecapRun?.trim();
|
|
1071
|
+
if (!selectedRun)
|
|
1072
|
+
return null;
|
|
1073
|
+
const verified = await loadVerifiedProjectRecap(options.projectPath, selectedRun);
|
|
1074
|
+
if (verified.manifest.artifacts.length !== 1)
|
|
1075
|
+
throw new CliError('Selected project recap must declare exactly one rendered HTML page.');
|
|
1076
|
+
const pagePath = verified.manifest.artifacts[0].renderedPath ??
|
|
1077
|
+
verified.manifest.artifacts[0].contentPath;
|
|
1078
|
+
if (!pagePath.startsWith('site/') || !pagePath.endsWith('.html'))
|
|
1079
|
+
throw new CliError('Selected project recap must declare a rendered HTML page.');
|
|
1080
|
+
const exportRoot = join(options.repoRoot, '.oat/repo/reference/project-recaps', `${snapshotName}.html`);
|
|
699
1081
|
const temporaryRoot = `${exportRoot}.tmp-${randomUUID()}`;
|
|
700
1082
|
const makeDir = dependencies.ensureDir ?? ensureDir;
|
|
701
1083
|
const copyFile = dependencies.copySingleFile ?? copySingleFile;
|
|
702
1084
|
const removePath = dependencies.removePath ??
|
|
703
1085
|
(async (target, removeOptions) => rm(target, removeOptions));
|
|
704
|
-
const renamePath = dependencies.renamePath ?? rename;
|
|
705
1086
|
await makeDir(dirname(exportRoot));
|
|
1087
|
+
await mkdir(temporaryRoot);
|
|
1088
|
+
const temporaryIdentity = await lstat(temporaryRoot);
|
|
706
1089
|
try {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
await makeDir(dirname(
|
|
710
|
-
await copyFile(join(sourceRunRoot,
|
|
711
|
-
}
|
|
712
|
-
const stagedManifestContents = await readFile(join(temporaryRoot, 'manifest.json'), 'utf8');
|
|
713
|
-
if (stagedManifestContents !== sourceManifestContents) {
|
|
714
|
-
throw new CliError('Selected project recap manifest changed while staging the export.');
|
|
1090
|
+
// Stage and re-verify the complete source, even though only its page is exported.
|
|
1091
|
+
for (const file of verified.packagePaths) {
|
|
1092
|
+
await makeDir(dirname(join(temporaryRoot, file)));
|
|
1093
|
+
await copyFile(join(verified.sourceRunRoot, file), join(temporaryRoot, file));
|
|
715
1094
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1095
|
+
if ((await readFile(join(temporaryRoot, 'manifest.json'), 'utf8')) !==
|
|
1096
|
+
verified.manifestContents)
|
|
1097
|
+
throw new CliError('Selected recap manifest changed while staging the export.');
|
|
1098
|
+
await verifyProjectRecapImmutableHashes(temporaryRoot, verified.manifest);
|
|
1099
|
+
await verifyRecapInventory(temporaryRoot, verified.packagePaths);
|
|
1100
|
+
const original = await readFile(join(temporaryRoot, pagePath));
|
|
1101
|
+
const transformed = await transformRecapPage(original.toString('utf8'), join(verified.sourceRunRoot, pagePath), verified.sourceRunRoot, temporaryRoot, exportRoot, options);
|
|
1102
|
+
const report = {
|
|
1103
|
+
sourceRunRoot: verified.sourceRunRoot,
|
|
1104
|
+
exportRoot,
|
|
1105
|
+
runId: verified.manifest.runId,
|
|
1106
|
+
page: {
|
|
1107
|
+
sourceRelativePath: pagePath,
|
|
1108
|
+
originalSha256: sha256(original),
|
|
1109
|
+
exportedSha256: sha256(transformed),
|
|
1110
|
+
},
|
|
1111
|
+
verifiedArtifactCount: verified.verifiedArtifactCount,
|
|
1112
|
+
};
|
|
1113
|
+
if (await pathExists(exportRoot)) {
|
|
1114
|
+
const stat = await lstat(exportRoot);
|
|
1115
|
+
if (stat.isFile()) {
|
|
1116
|
+
const existingHash = sha256(await readFile(exportRoot));
|
|
1117
|
+
if (existingHash !== report.page.exportedSha256 &&
|
|
1118
|
+
options.summaryExportPath) {
|
|
1119
|
+
// A warning-only completion may have published this verified package
|
|
1120
|
+
// without its unavailable summary link. Preserve that page on retry,
|
|
1121
|
+
// including when the summary destination has since been repaired.
|
|
1122
|
+
const withoutSummary = await transformRecapPage(original.toString('utf8'), join(verified.sourceRunRoot, pagePath), verified.sourceRunRoot, temporaryRoot, exportRoot, { ...options, summaryExportPath: null });
|
|
1123
|
+
if (sha256(withoutSummary) === existingHash)
|
|
1124
|
+
report.page.exportedSha256 = existingHash;
|
|
1125
|
+
}
|
|
1126
|
+
if (existingHash === report.page.exportedSha256)
|
|
1127
|
+
return { export: report, createdByAttempt: false };
|
|
1128
|
+
}
|
|
1129
|
+
throw new CliError(`Existing project recap export ${exportRoot} already exists and does not match persisted snapshot ${snapshotName}.`);
|
|
724
1130
|
}
|
|
725
|
-
|
|
1131
|
+
const temporaryPage = join(temporaryRoot, 'export.html');
|
|
1132
|
+
await writeFile(temporaryPage, transformed, { flag: 'wx' });
|
|
1133
|
+
// A hard-link promotion is atomic and refuses an intervening writer, unlike rename.
|
|
1134
|
+
await link(temporaryPage, exportRoot);
|
|
1135
|
+
const identity = await lstat(temporaryPage);
|
|
726
1136
|
return {
|
|
727
|
-
export:
|
|
728
|
-
sourceRunRoot,
|
|
729
|
-
exportRoot,
|
|
730
|
-
manifest: {
|
|
731
|
-
relativePath: 'manifest.json',
|
|
732
|
-
verifiedArtifactCount,
|
|
733
|
-
},
|
|
734
|
-
},
|
|
1137
|
+
export: report,
|
|
735
1138
|
createdByAttempt: true,
|
|
1139
|
+
identity: {
|
|
1140
|
+
dev: identity.dev,
|
|
1141
|
+
ino: identity.ino,
|
|
1142
|
+
birthtimeMs: identity.birthtimeMs,
|
|
1143
|
+
},
|
|
736
1144
|
};
|
|
737
1145
|
}
|
|
738
|
-
|
|
739
|
-
await
|
|
740
|
-
throw error;
|
|
1146
|
+
finally {
|
|
1147
|
+
await cleanupOwnedRecapPath(temporaryRoot, temporaryIdentity, removePath, dependencies.renamePath ?? rename);
|
|
741
1148
|
}
|
|
742
1149
|
}
|
|
743
1150
|
export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
@@ -854,8 +1261,8 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
854
1261
|
const projectSourcePath = (await pathExists(options.projectPath))
|
|
855
1262
|
? options.projectPath
|
|
856
1263
|
: archivePath;
|
|
857
|
-
|
|
858
|
-
|
|
1264
|
+
let attemptedProjectRecapExport = await exportSelectedProjectRecap({ ...options, projectPath: projectSourcePath }, exportIdentity, dependencies);
|
|
1265
|
+
let projectRecapExport = attemptedProjectRecapExport?.export ?? null;
|
|
859
1266
|
if (!archiveExists) {
|
|
860
1267
|
try {
|
|
861
1268
|
await makeDir(dirname(archivePath));
|
|
@@ -870,47 +1277,57 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
870
1277
|
}
|
|
871
1278
|
catch (error) {
|
|
872
1279
|
await removePath(archivePath, { recursive: true, force: true });
|
|
873
|
-
|
|
874
|
-
await removePath(attemptedProjectRecapExport.export.exportRoot, {
|
|
875
|
-
recursive: true,
|
|
876
|
-
force: true,
|
|
877
|
-
});
|
|
878
|
-
}
|
|
1280
|
+
await removeAttemptRecapExport(attemptedProjectRecapExport, removePath, dependencies.renamePath ?? rename);
|
|
879
1281
|
throw error;
|
|
880
1282
|
}
|
|
881
1283
|
}
|
|
882
1284
|
try {
|
|
1285
|
+
if (projectRecapExport && options.projectRecapRun) {
|
|
1286
|
+
const archivedRecap = await loadVerifiedProjectRecap(archivePath, options.projectRecapRun);
|
|
1287
|
+
const archivedPage = projectRecapExport.page.sourceRelativePath;
|
|
1288
|
+
if (archivedRecap.manifest.runId !== projectRecapExport.runId ||
|
|
1289
|
+
sha256(await readFile(join(archivedRecap.sourceRunRoot, archivedPage))) !== projectRecapExport.page.originalSha256) {
|
|
1290
|
+
throw new CliError('Archived recap identity differs from the verified exported page.');
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
883
1293
|
if (!syncTarget) {
|
|
884
1294
|
await removePath(options.projectPath, { recursive: true, force: true });
|
|
885
1295
|
}
|
|
886
1296
|
}
|
|
887
1297
|
catch (error) {
|
|
888
|
-
|
|
889
|
-
await removePath(attemptedProjectRecapExport.export.exportRoot, {
|
|
890
|
-
recursive: true,
|
|
891
|
-
force: true,
|
|
892
|
-
});
|
|
893
|
-
}
|
|
1298
|
+
await removeAttemptRecapExport(attemptedProjectRecapExport, removePath, dependencies.renamePath ?? rename);
|
|
894
1299
|
throw error;
|
|
895
1300
|
}
|
|
896
1301
|
const warnings = [];
|
|
897
1302
|
let summaryExportFile = null;
|
|
898
1303
|
if (options.summaryExportPath) {
|
|
899
1304
|
try {
|
|
900
|
-
summaryExportFile = await exportProjectSummary(archivePath, exportIdentity, options.summaryExportPath, options.repoRoot, dependencies);
|
|
1305
|
+
summaryExportFile = await exportProjectSummary(archivePath, exportIdentity, options.summaryExportPath, options.repoRoot, dependencies, projectRecapExport);
|
|
901
1306
|
}
|
|
902
1307
|
catch (error) {
|
|
903
1308
|
const message = error instanceof Error ? error.message : String(error);
|
|
1309
|
+
const summaryFailure = `Summary export to \`${options.summaryExportPath}\` failed: ${message}`;
|
|
904
1310
|
if (syncTarget) {
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1311
|
+
await removeAttemptRecapExport(attemptedProjectRecapExport, removePath, dependencies.renamePath ?? rename);
|
|
1312
|
+
throw new CliError(summaryFailure, 1);
|
|
1313
|
+
}
|
|
1314
|
+
warnings.push(summaryFailure);
|
|
1315
|
+
if (attemptedProjectRecapExport) {
|
|
1316
|
+
// Rebuild only through the existing identity/hash-aware cleanup and
|
|
1317
|
+
// no-clobber publisher. Adopted or foreign linked output is preserved
|
|
1318
|
+
// and refused if it differs from the required link-free page.
|
|
1319
|
+
try {
|
|
1320
|
+
await removeAttemptRecapExport(attemptedProjectRecapExport, removePath, dependencies.renamePath ?? rename);
|
|
1321
|
+
attemptedProjectRecapExport = await exportSelectedProjectRecap({ ...options, projectPath: archivePath, summaryExportPath: null }, exportIdentity, dependencies);
|
|
1322
|
+
projectRecapExport = attemptedProjectRecapExport?.export ?? null;
|
|
1323
|
+
}
|
|
1324
|
+
catch (repairError) {
|
|
1325
|
+
const repairMessage = repairError instanceof Error
|
|
1326
|
+
? repairError.message
|
|
1327
|
+
: String(repairError);
|
|
1328
|
+
throw new CliError(`${summaryFailure}\nRecap repair without the summary link failed: ${repairMessage}\nRepair the summary destination \`${options.summaryExportPath}\` and retry archive completion.`, repairError instanceof CliError ? repairError.exitCode : 2);
|
|
910
1329
|
}
|
|
911
|
-
throw new CliError(`Summary export to \`${options.summaryExportPath}\` failed: ${message}`, 1);
|
|
912
1330
|
}
|
|
913
|
-
warnings.push(`Summary export to \`${options.summaryExportPath}\` failed: ${message}`);
|
|
914
1331
|
}
|
|
915
1332
|
}
|
|
916
1333
|
let s3Path = null;
|
|
@@ -973,8 +1390,7 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
973
1390
|
throw new CliError(`Synced checkout became ${removed.status} during archive; run oat project push ${options.projectName} and retry.`, 1);
|
|
974
1391
|
}
|
|
975
1392
|
if (projectRecapExport) {
|
|
976
|
-
recapExportPaths =
|
|
977
|
-
basename(filePath) !== 'build-record.json');
|
|
1393
|
+
recapExportPaths = [projectRecapExport.exportRoot];
|
|
978
1394
|
}
|
|
979
1395
|
await (dependencies.removeSyncedRecord
|
|
980
1396
|
? dependencies.removeSyncedRecord(recordPath)
|
|
@@ -1057,16 +1473,6 @@ async function recoverSyncedLifecycleCommit(repoRoot, pathspecs, projectName, ex
|
|
|
1057
1473
|
}
|
|
1058
1474
|
return candidate;
|
|
1059
1475
|
}
|
|
1060
|
-
async function listArchiveExportFiles(root, current = root) {
|
|
1061
|
-
const entries = await readdir(current, { withFileTypes: true });
|
|
1062
|
-
const files = await Promise.all(entries.map(async (entry) => {
|
|
1063
|
-
const entryPath = join(current, entry.name);
|
|
1064
|
-
return entry.isDirectory()
|
|
1065
|
-
? listArchiveExportFiles(root, entryPath)
|
|
1066
|
-
: [entryPath];
|
|
1067
|
-
}));
|
|
1068
|
-
return files.flat().sort();
|
|
1069
|
-
}
|
|
1070
1476
|
export async function ensureS3ArchiveAccess(options, dependencies = {}) {
|
|
1071
1477
|
if (!requiresRemoteAccess(options)) {
|
|
1072
1478
|
return { ok: true, warnings: [] };
|