@doxbrix/doxloop 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1153 -0
- package/README.md +422 -226
- package/assets/doxbrix-preview.css +77 -15
- package/contracts/agent-events-v1.schema.json +1 -0
- package/contracts/coverage-v1.schema.json +59 -0
- package/contracts/drift-v1.schema.json +1 -0
- package/contracts/evaluation-v1.schema.json +62 -0
- package/contracts/quality-codes-v1.json +1 -0
- package/contracts/quality-report-v1.schema.json +1 -0
- package/contracts/validation-v1.schema.json +1 -0
- package/dist/agent-failure.d.ts +36 -0
- package/dist/agent-failure.js +98 -0
- package/dist/agent-isolation.d.ts +17 -0
- package/dist/agent-isolation.js +94 -0
- package/dist/agent-log.d.ts +197 -0
- package/dist/agent-log.js +1094 -0
- package/dist/agent-process.d.ts +63 -0
- package/dist/agent-process.js +128 -0
- package/dist/agent-reply.d.ts +47 -0
- package/dist/agent-reply.js +315 -0
- package/dist/agents.d.ts +40 -4
- package/dist/agents.js +115 -11
- package/dist/api-coverage.d.ts +11 -0
- package/dist/api-coverage.js +72 -0
- package/dist/application-probe.d.ts +28 -0
- package/dist/application-probe.js +93 -0
- package/dist/application-url.d.ts +14 -0
- package/dist/application-url.js +26 -0
- package/dist/args.js +15 -0
- package/dist/artifact-deploy.d.ts +9 -0
- package/dist/artifact-deploy.js +28 -8
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +382 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +38 -9
- package/dist/author.d.ts +206 -4
- package/dist/author.js +1650 -52
- package/dist/authoring-batches.d.ts +190 -0
- package/dist/authoring-batches.js +489 -0
- package/dist/authoring-postpass.d.ts +79 -0
- package/dist/authoring-postpass.js +1289 -0
- package/dist/authoring-progress.d.ts +95 -0
- package/dist/authoring-progress.js +269 -0
- package/dist/autosync.d.ts +56 -0
- package/dist/autosync.js +442 -0
- package/dist/batch-artifacts.d.ts +81 -0
- package/dist/batch-artifacts.js +412 -0
- package/dist/batch-limits.d.ts +37 -0
- package/dist/batch-limits.js +80 -0
- package/dist/branding.d.ts +41 -0
- package/dist/branding.js +229 -0
- package/dist/bundle-upload.d.ts +26 -0
- package/dist/bundle-upload.js +99 -0
- package/dist/capture-auth.d.ts +95 -0
- package/dist/capture-auth.js +204 -0
- package/dist/capture.d.ts +48 -0
- package/dist/capture.js +20 -1
- package/dist/cli.js +839 -32
- package/dist/content-links.d.ts +10 -0
- package/dist/content-links.js +49 -0
- package/dist/contract-validation.d.ts +4 -0
- package/dist/contract-validation.js +25 -0
- package/dist/coverage-actions.d.ts +8 -0
- package/dist/coverage-actions.js +71 -0
- package/dist/coverage-resolutions.d.ts +16 -0
- package/dist/coverage-resolutions.js +58 -0
- package/dist/db.d.ts +37 -0
- package/dist/db.js +288 -0
- package/dist/demo.d.ts +12 -0
- package/dist/demo.js +122 -0
- package/dist/deploy-credentials.d.ts +7 -0
- package/dist/deploy-credentials.js +76 -0
- package/dist/deploy-targets/github-pages.d.ts +3 -0
- package/dist/deploy-targets/github-pages.js +100 -0
- package/dist/deploy-targets/index.d.ts +8 -0
- package/dist/deploy-targets/index.js +78 -0
- package/dist/deploy-targets/netlify.d.ts +3 -0
- package/dist/deploy-targets/netlify.js +49 -0
- package/dist/deploy-targets/types.d.ts +32 -0
- package/dist/deploy-targets/types.js +2 -0
- package/dist/deploy-targets/vercel.d.ts +3 -0
- package/dist/deploy-targets/vercel.js +95 -0
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +87 -13
- package/dist/deterministic-capture.d.ts +90 -0
- package/dist/deterministic-capture.js +435 -0
- package/dist/direct-edit.d.ts +40 -0
- package/dist/direct-edit.js +159 -0
- package/dist/docs-crawl.d.ts +86 -0
- package/dist/docs-crawl.js +536 -0
- package/dist/docs-site.d.ts +49 -0
- package/dist/docs-site.js +233 -0
- package/dist/doctor.js +8 -0
- package/dist/documentation-collections.d.ts +23 -0
- package/dist/documentation-collections.js +191 -0
- package/dist/documentation-plan.d.ts +253 -0
- package/dist/documentation-plan.js +2563 -0
- package/dist/doxbrix-build.d.ts +19 -0
- package/dist/doxbrix-build.js +167 -0
- package/dist/doxbrix-markdown.d.ts +9 -0
- package/dist/doxbrix-markdown.js +115 -14
- package/dist/drift.d.ts +10 -0
- package/dist/drift.js +164 -0
- package/dist/evaluation.d.ts +46 -0
- package/dist/evaluation.js +113 -0
- package/dist/evidence-pack.d.ts +47 -0
- package/dist/evidence-pack.js +358 -0
- package/dist/evidence.d.ts +25 -0
- package/dist/evidence.js +175 -0
- package/dist/fs.d.ts +8 -2
- package/dist/fs.js +41 -11
- package/dist/generator-api.d.ts +89 -0
- package/dist/generator-preflight.d.ts +27 -0
- package/dist/generator-preflight.js +105 -0
- package/dist/generator-runtime.d.ts +7 -0
- package/dist/generator-runtime.js +17 -1
- package/dist/generators.d.ts +24 -2
- package/dist/generators.js +54 -1
- package/dist/git-delivery.d.ts +17 -0
- package/dist/git-delivery.js +123 -0
- package/dist/globs.d.ts +16 -0
- package/dist/globs.js +65 -0
- package/dist/glossary.d.ts +26 -0
- package/dist/glossary.js +179 -0
- package/dist/history.d.ts +106 -0
- package/dist/history.js +600 -0
- package/dist/html-markdown.d.ts +46 -0
- package/dist/html-markdown.js +423 -0
- package/dist/interactive.js +16 -15
- package/dist/job-events.d.ts +74 -0
- package/dist/job-events.js +377 -0
- package/dist/keep-awake.d.ts +50 -0
- package/dist/keep-awake.js +123 -0
- package/dist/local-source-snapshot.d.ts +20 -0
- package/dist/local-source-snapshot.js +61 -0
- package/dist/mintlify-detect.d.ts +3 -0
- package/dist/mintlify-detect.js +18 -0
- package/dist/mintlify-import.d.ts +75 -0
- package/dist/mintlify-import.js +190 -0
- package/dist/navigation.d.ts +98 -0
- package/dist/navigation.js +310 -0
- package/dist/openapi.d.ts +60 -0
- package/dist/openapi.js +439 -0
- package/dist/page-editor-bridge.d.ts +3 -0
- package/dist/page-editor-bridge.js +109 -0
- package/dist/page-editor-preview.d.ts +10 -0
- package/dist/page-editor-preview.js +55 -0
- package/dist/page-extension.d.ts +9 -0
- package/dist/page-extension.js +15 -0
- package/dist/page-metadata.d.ts +28 -0
- package/dist/page-metadata.js +166 -0
- package/dist/page-operations.d.ts +34 -0
- package/dist/page-operations.js +215 -0
- package/dist/page-routes.d.ts +4 -0
- package/dist/page-routes.js +61 -0
- package/dist/pages.d.ts +20 -0
- package/dist/pages.js +184 -0
- package/dist/plan-generator.d.ts +3 -0
- package/dist/plan-generator.js +21 -0
- package/dist/plan-navigation.d.ts +11 -0
- package/dist/plan-navigation.js +30 -0
- package/dist/planning-captures.d.ts +20 -0
- package/dist/planning-captures.js +143 -0
- package/dist/planning-research.d.ts +135 -0
- package/dist/planning-research.js +472 -0
- package/dist/planning-triage.d.ts +23 -0
- package/dist/planning-triage.js +131 -0
- package/dist/preview.d.ts +24 -0
- package/dist/preview.js +280 -29
- package/dist/project-detect.d.ts +36 -0
- package/dist/project-detect.js +251 -0
- package/dist/project-import.d.ts +54 -0
- package/dist/project-import.js +157 -0
- package/dist/project-lock.d.ts +6 -0
- package/dist/project-lock.js +96 -0
- package/dist/project-registry.d.ts +25 -0
- package/dist/project-registry.js +79 -0
- package/dist/project.d.ts +35 -3
- package/dist/project.js +388 -38
- package/dist/prompts.d.ts +9 -0
- package/dist/prompts.js +33 -4
- package/dist/proposal-replay.d.ts +32 -0
- package/dist/proposal-replay.js +99 -0
- package/dist/quality-claims.d.ts +8 -0
- package/dist/quality-claims.js +168 -0
- package/dist/quality-config.d.ts +5 -0
- package/dist/quality-config.js +84 -0
- package/dist/quality-contract.d.ts +37 -0
- package/dist/quality-contract.js +46 -0
- package/dist/quality-examples.d.ts +4 -0
- package/dist/quality-examples.js +233 -0
- package/dist/quality-gates.d.ts +16 -0
- package/dist/quality-gates.js +192 -0
- package/dist/quality-links.d.ts +7 -0
- package/dist/quality-links.js +149 -0
- package/dist/quality-lint.d.ts +6 -0
- package/dist/quality-lint.js +124 -0
- package/dist/quality-rendered.d.ts +11 -0
- package/dist/quality-rendered.js +222 -0
- package/dist/quality-schema.d.ts +3 -0
- package/dist/quality-schema.js +71 -0
- package/dist/release-notes.d.ts +44 -0
- package/dist/release-notes.js +183 -0
- package/dist/remote-monitor.d.ts +16 -0
- package/dist/remote-monitor.js +74 -0
- package/dist/remote-source.d.ts +34 -0
- package/dist/remote-source.js +426 -0
- package/dist/review-diff.d.ts +82 -0
- package/dist/review-diff.js +400 -0
- package/dist/review-learning.d.ts +11 -0
- package/dist/review-learning.js +60 -0
- package/dist/review-render.d.ts +38 -0
- package/dist/review-render.js +224 -0
- package/dist/review-report.d.ts +9 -0
- package/dist/review-report.js +89 -0
- package/dist/review-ui.d.ts +14 -0
- package/dist/review-ui.js +1248 -0
- package/dist/schedule.d.ts +78 -0
- package/dist/schedule.js +480 -0
- package/dist/screen-capture-provider.d.ts +52 -0
- package/dist/screen-capture-provider.js +218 -0
- package/dist/screenshot-workflow.d.ts +167 -0
- package/dist/screenshot-workflow.js +1237 -0
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +95 -7
- package/dist/site-export.d.ts +18 -0
- package/dist/site-export.js +87 -0
- package/dist/source-connectors.d.ts +33 -0
- package/dist/source-connectors.js +268 -0
- package/dist/source-discovery.d.ts +132 -0
- package/dist/source-discovery.js +823 -0
- package/dist/source-intelligence.d.ts +9 -0
- package/dist/source-intelligence.js +306 -0
- package/dist/sync-review.d.ts +28 -0
- package/dist/sync-review.js +264 -0
- package/dist/sync-runs.d.ts +192 -0
- package/dist/sync-runs.js +2244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +298 -32
- package/dist/text-diff.d.ts +9 -0
- package/dist/text-diff.js +59 -0
- package/dist/types.d.ts +946 -1
- package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
- package/dist/ui/assets/index-BHBYU2aG.css +1 -0
- package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
- package/dist/ui/index.html +18 -0
- package/dist/ui-server.d.ts +83 -0
- package/dist/ui-server.js +3532 -0
- package/dist/usage-budget.d.ts +28 -0
- package/dist/usage-budget.js +90 -0
- package/dist/validation.d.ts +25 -1
- package/dist/validation.js +312 -26
- package/dist/workspace-tools.d.ts +54 -0
- package/dist/workspace-tools.js +123 -0
- package/docs/agent-compatibility.md +50 -28
- package/docs/ci-and-automation.md +105 -66
- package/docs/doxbrix-http-api.md +8 -1
- package/docs/existing-documentation.md +80 -0
- package/docs/generation-performance.md +108 -0
- package/docs/generator-authoring.md +68 -5
- package/docs/generator-selection.md +50 -13
- package/docs/mintlify-import.md +71 -0
- package/docs/openapi-security.md +25 -0
- package/docs/project-format.md +331 -36
- package/docs/release-quality.md +158 -0
- package/docs/releasing.md +72 -0
- package/docs/review-workflows.md +51 -0
- package/docs/security-model.md +102 -39
- package/docs/troubleshooting.md +226 -93
- package/package.json +46 -15
- package/scripts/test-auto-screenshot.mjs +172 -0
- package/skills/doxloop-authoring/SKILL.md +242 -361
- package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
- package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
- package/skills/doxloop-authoring/references/page-depth.md +169 -0
- package/skills/doxloop-authoring/references/project-format.md +106 -7
- package/skills/doxloop-authoring/references/quality.md +10 -0
- package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
- package/skills/doxloop-authoring/references/screenshots.md +149 -235
- package/skills/doxloop-authoring/references/workflows.md +76 -0
- package/skills/doxloop-doxbrix/SKILL.md +38 -22
- package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
- package/skills/doxloop-doxbrix/references/components.md +30 -3
- package/skills/doxloop-doxbrix/references/manifest.md +3 -2
- package/vendor/doxbrix-import/LICENSE +202 -0
- package/vendor/doxbrix-import/README.md +17 -0
- package/vendor/doxbrix-import/UPSTREAM.json +47 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
- package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
- package/vendor/doxbrix-import/dist/docs/import.js +147 -0
- package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
- package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
- package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
- package/vendor/doxbrix-import/dist/docs/project.js +77 -0
- package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
- package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
- package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
- package/vendor/doxbrix-import/dist/importer.js +1567 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
- package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
- package/vendor/doxbrix-import/dist/safe-path.js +47 -0
- package/dist/agents.d.ts.map +0 -1
- package/dist/agents.js.map +0 -1
- package/dist/args.d.ts.map +0 -1
- package/dist/args.js.map +0 -1
- package/dist/artifact-deploy.d.ts.map +0 -1
- package/dist/artifact-deploy.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/author.d.ts.map +0 -1
- package/dist/author.js.map +0 -1
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/deploy.d.ts.map +0 -1
- package/dist/deploy.js.map +0 -1
- package/dist/deployment-visibility.d.ts.map +0 -1
- package/dist/deployment-visibility.js.map +0 -1
- package/dist/doctor.d.ts.map +0 -1
- package/dist/doctor.js.map +0 -1
- package/dist/doxbrix-markdown.d.ts.map +0 -1
- package/dist/doxbrix-markdown.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/fs.d.ts.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/generator-api.d.ts.map +0 -1
- package/dist/generator-api.js.map +0 -1
- package/dist/generator-manager.d.ts.map +0 -1
- package/dist/generator-manager.js.map +0 -1
- package/dist/generator-runtime.d.ts.map +0 -1
- package/dist/generator-runtime.js.map +0 -1
- package/dist/generators.d.ts.map +0 -1
- package/dist/generators.js.map +0 -1
- package/dist/interactive.d.ts.map +0 -1
- package/dist/interactive.js.map +0 -1
- package/dist/preview.d.ts.map +0 -1
- package/dist/preview.js.map +0 -1
- package/dist/progress.d.ts.map +0 -1
- package/dist/progress.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/settings.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, normalize, relative, resolve } from 'node:path';
|
|
3
|
+
import { PNG } from 'pngjs';
|
|
4
|
+
import { applicationUrl } from './application-url.js';
|
|
5
|
+
import { ensurePlaywright } from './capture.js';
|
|
6
|
+
import { DoxloopError } from './errors.js';
|
|
7
|
+
import { pathExists } from './fs.js';
|
|
8
|
+
import { GUIDE_ASSET_ROOTS, SCREENSHOT_MANIFEST_FILE, dominantColorShare } from './screenshot-workflow.js';
|
|
9
|
+
const DEFAULT_TIME_BUDGET_MS = 4 * 60_000;
|
|
10
|
+
const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
|
|
11
|
+
const GOTO_TIMEOUT_MS = 30_000;
|
|
12
|
+
const SETTLE_ATTEMPTS = 4;
|
|
13
|
+
const SETTLE_DELAY_MS = 1_200;
|
|
14
|
+
/** Share of one colour at or above which a screenshot is treated as blank or still loading. */
|
|
15
|
+
const UNIFORM_SHARE = 0.97;
|
|
16
|
+
const MIN_WIDTH = 320;
|
|
17
|
+
const MIN_HEIGHT = 180;
|
|
18
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
19
|
+
const NAVIGATION_VERB = /^(open|go to|navigate to|visit|load|start (at|on))\b/i;
|
|
20
|
+
const INTERACTION_VERB = /\b(click|select|type|fill|choose|press|toggle|drag|enter|submit|expand|collapse|hover|scroll|sign in|log in|upload|create|delete|save|edit|switch)(s|es|ed|ing)?\b/i;
|
|
21
|
+
/** App-relative path token, only where a path can plausibly start (not inside "and/or" or a URL scheme). */
|
|
22
|
+
const PATH_TOKEN = /(?:^|[\s"'`(<[])(\/[A-Za-z0-9_\-./?=&%#]*)/;
|
|
23
|
+
const ABSOLUTE_URL = /\bhttps?:\/\/[^\s"'`)>\]]+/i;
|
|
24
|
+
const LOGIN_ROUTE = /(login|sign-?in|signin|auth\/|oauth|sso)/i;
|
|
25
|
+
/**
|
|
26
|
+
* Decide whether a manifest action only opens a screen.
|
|
27
|
+
*
|
|
28
|
+
* Returns the explicit app-relative path when the action names one, an empty
|
|
29
|
+
* object when it is a bare "open the app" instruction, and `undefined` when
|
|
30
|
+
* the step needs interaction (or does not start by navigating).
|
|
31
|
+
*/
|
|
32
|
+
export function isNavigationOnlyStep(action) {
|
|
33
|
+
const text = action.trim();
|
|
34
|
+
if (!NAVIGATION_VERB.test(text) || needsInteraction(text))
|
|
35
|
+
return undefined;
|
|
36
|
+
const path = extractPath(text);
|
|
37
|
+
return path ? { path } : {};
|
|
38
|
+
}
|
|
39
|
+
/** Whether the prose (routes removed, so "/settings/upload" is not an upload) names an interaction. */
|
|
40
|
+
function needsInteraction(text) {
|
|
41
|
+
const prose = text.replace(new RegExp(ABSOLUTE_URL.source, 'gi'), ' ').replace(new RegExp(PATH_TOKEN.source, 'g'), ' ');
|
|
42
|
+
return INTERACTION_VERB.test(prose);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the route a step should be captured at, or `undefined` when the step
|
|
46
|
+
* is not navigation-only.
|
|
47
|
+
*
|
|
48
|
+
* The first step of a guide is the entry screen by construction, so it needs
|
|
49
|
+
* no explicit route: `visuals.startPath` stands in when the action carries
|
|
50
|
+
* none. Every later step qualifies only when its action names a route, since
|
|
51
|
+
* a bare "open the dialog" describes interaction, not navigation.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveStepPath(page, step, index) {
|
|
54
|
+
const navigation = isNavigationOnlyStep(step.action);
|
|
55
|
+
if (navigation?.path)
|
|
56
|
+
return navigation.path;
|
|
57
|
+
if (index !== 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
// The entry screen: any first step that involves no interaction shows the
|
|
60
|
+
// start route, whether or not it is phrased as "Open ...".
|
|
61
|
+
if (!navigation && needsInteraction(step.action.trim()))
|
|
62
|
+
return undefined;
|
|
63
|
+
const explicit = extractPath(step.action.trim());
|
|
64
|
+
return explicit ?? page.visuals?.startPath ?? '/';
|
|
65
|
+
}
|
|
66
|
+
/** Capture every navigation-only planned step with a real browser (or the injected seam). */
|
|
67
|
+
export async function captureNavigableSteps(input) {
|
|
68
|
+
const result = { captured: [], skipped: [] };
|
|
69
|
+
const log = input.log ?? (() => { });
|
|
70
|
+
const baseUrl = input.project.application?.baseUrl?.trim();
|
|
71
|
+
const manifestPath = join(input.workspace, SCREENSHOT_MANIFEST_FILE);
|
|
72
|
+
if (!baseUrl || !(await pathExists(manifestPath)))
|
|
73
|
+
return result;
|
|
74
|
+
let manifest;
|
|
75
|
+
try {
|
|
76
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(manifest.guides))
|
|
82
|
+
return result;
|
|
83
|
+
let base;
|
|
84
|
+
try {
|
|
85
|
+
base = new URL(baseUrl);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
const pages = (input.pages ?? input.plan.pages).filter((page) => page.visuals && page.visuals.mode !== 'none');
|
|
91
|
+
const candidates = collectCandidates(manifest, pages, base, result);
|
|
92
|
+
if (candidates.length === 0)
|
|
93
|
+
return result;
|
|
94
|
+
const contentDir = input.project.contentDir || input.plan.target?.contentDir || '';
|
|
95
|
+
const assetRoot = GUIDE_ASSET_ROOTS[input.project.generator] ?? 'assets/guides';
|
|
96
|
+
const budget = input.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
|
|
97
|
+
const started = Date.now();
|
|
98
|
+
const browser = input.capturePage ? undefined : await openBrowser(input);
|
|
99
|
+
const capturePage = input.capturePage ?? browser.capturePage;
|
|
100
|
+
const signIn = input.signIn ?? browser?.signIn;
|
|
101
|
+
let consecutive;
|
|
102
|
+
let dirty = false;
|
|
103
|
+
// One sign-in attempt per run: it either works for the whole browser
|
|
104
|
+
// session or the states stay with the agent.
|
|
105
|
+
let signInState = 'untried';
|
|
106
|
+
const attemptCapture = async (candidate) => {
|
|
107
|
+
const { page, step, url, requestedPath } = candidate;
|
|
108
|
+
try {
|
|
109
|
+
return await captureOne({ input, page, step, url, requestedPath, contentDir, assetRoot, capturePage });
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return { reason: `error: ${error instanceof Error ? error.message : String(error)}` };
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
for (const candidate of candidates) {
|
|
117
|
+
const { page, step, requestedPath } = candidate;
|
|
118
|
+
const label = `${page.id}/${step.id}`;
|
|
119
|
+
if (Date.now() - started >= budget) {
|
|
120
|
+
result.skipped.push({ page: page.id, step: step.id, reason: 'time budget exhausted' });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (consecutive && consecutive.count >= MAX_CONSECUTIVE_FAILURES) {
|
|
124
|
+
result.skipped.push({
|
|
125
|
+
page: page.id,
|
|
126
|
+
step: step.id,
|
|
127
|
+
reason: `stopped after ${MAX_CONSECUTIVE_FAILURES} consecutive failures (${consecutive.reason})`,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
let outcome = await attemptCapture(candidate);
|
|
132
|
+
if ('reason' in outcome && outcome.reason.startsWith('sign-in required') && input.credentials && signIn && signInState === 'untried') {
|
|
133
|
+
const loginUrl = new URL(input.loginPath?.trim() || '/login', base).toString();
|
|
134
|
+
try {
|
|
135
|
+
signInState = (await signIn(loginUrl, input.credentials)) ? 'signed-in' : 'failed';
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
signInState = 'failed';
|
|
139
|
+
log(`Sign-in with the saved credentials failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
140
|
+
}
|
|
141
|
+
log(signInState === 'signed-in' ? `Signed in at ${loginUrl} with the saved credentials.` : `Sign-in at ${loginUrl} with the saved credentials did not reach a signed-in page; sign-in states are left to the agent.`);
|
|
142
|
+
if (signInState === 'signed-in')
|
|
143
|
+
outcome = await attemptCapture(candidate);
|
|
144
|
+
}
|
|
145
|
+
if ('file' in outcome) {
|
|
146
|
+
consecutive = undefined;
|
|
147
|
+
dirty = true;
|
|
148
|
+
result.captured.push({ page: page.id, step: step.id, file: outcome.file });
|
|
149
|
+
log(`Captured ${label} at ${requestedPath} -> ${outcome.file}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const reasonClass = classifyReason(outcome.reason);
|
|
153
|
+
consecutive = consecutive?.reason === reasonClass ? { reason: reasonClass, count: consecutive.count + 1 } : { reason: reasonClass, count: 1 };
|
|
154
|
+
result.skipped.push({ page: page.id, step: step.id, reason: outcome.reason });
|
|
155
|
+
log(`Left ${label} at ${requestedPath} to the agent: ${outcome.reason}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await browser?.close();
|
|
160
|
+
if (dirty)
|
|
161
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
/** Pick the planned, navigation-only steps, dropping repeats of a screen within one guide. */
|
|
166
|
+
function collectCandidates(manifest, pages, base, result) {
|
|
167
|
+
const candidates = [];
|
|
168
|
+
for (const guide of manifest.guides) {
|
|
169
|
+
if (!guide || typeof guide.page !== 'string' || !Array.isArray(guide.steps))
|
|
170
|
+
continue;
|
|
171
|
+
const page = pages.find((item) => item.id === guide.page || item.path === guide.page);
|
|
172
|
+
if (!page)
|
|
173
|
+
continue;
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
// Screens already proven by an earlier run count as captured for de-duplication.
|
|
176
|
+
for (const [index, step] of guide.steps.entries()) {
|
|
177
|
+
if (!step || (step.status === 'planned' && !step.file))
|
|
178
|
+
continue;
|
|
179
|
+
const path = resolveStepPath(page, step, index);
|
|
180
|
+
const key = path ? screenKey(path, base) : undefined;
|
|
181
|
+
if (key)
|
|
182
|
+
seen.add(key);
|
|
183
|
+
}
|
|
184
|
+
for (const [index, step] of guide.steps.entries()) {
|
|
185
|
+
if (!step || typeof step.id !== 'string' || typeof step.action !== 'string')
|
|
186
|
+
continue;
|
|
187
|
+
if (step.status !== 'planned' || step.file)
|
|
188
|
+
continue;
|
|
189
|
+
const path = resolveStepPath(page, step, index);
|
|
190
|
+
if (!path)
|
|
191
|
+
continue;
|
|
192
|
+
let url;
|
|
193
|
+
try {
|
|
194
|
+
url = applicationUrl(base.toString(), path);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
result.skipped.push({ page: page.id, step: step.id, reason: `invalid path "${path}"` });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (url.origin !== base.origin) {
|
|
201
|
+
result.skipped.push({ page: page.id, step: step.id, reason: `path "${path}" leaves ${base.origin}` });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const key = screenKey(path, base);
|
|
205
|
+
if (!key || seen.has(key)) {
|
|
206
|
+
result.skipped.push({ page: page.id, step: step.id, reason: `duplicate screen: ${path} is already captured for this guide` });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
seen.add(key);
|
|
210
|
+
candidates.push({ page, step, url: url.toString(), requestedPath: path });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return candidates;
|
|
214
|
+
}
|
|
215
|
+
async function captureOne(options) {
|
|
216
|
+
const { input, page, step, url, requestedPath, contentDir, assetRoot, capturePage } = options;
|
|
217
|
+
if (!/^[A-Za-z0-9._-]+$/.test(page.id))
|
|
218
|
+
return { reason: `page id "${page.id}" is not a safe folder name` };
|
|
219
|
+
const stepId = step.id.trim().replace(/[^A-Za-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
220
|
+
if (!stepId)
|
|
221
|
+
return { reason: `step id "${step.id}" is not a safe file name` };
|
|
222
|
+
const file = join(contentDir, assetRoot, page.id, `${stepId}-${slugify(step.expectedState)}.png`).replaceAll('\\', '/');
|
|
223
|
+
const target = safeWorkspacePath(input.workspace, file);
|
|
224
|
+
if (await pathExists(target))
|
|
225
|
+
return { reason: `${file} already exists` };
|
|
226
|
+
const shot = await capturePage(url);
|
|
227
|
+
if (shot.status !== undefined && shot.status >= 400)
|
|
228
|
+
return { reason: `status ${shot.status} at ${requestedPath}` };
|
|
229
|
+
const requestedLogin = LOGIN_ROUTE.test(requestedPath);
|
|
230
|
+
if (!requestedLogin) {
|
|
231
|
+
let finalPath = shot.finalUrl;
|
|
232
|
+
try {
|
|
233
|
+
const parsed = new URL(shot.finalUrl);
|
|
234
|
+
finalPath = `${parsed.pathname}${parsed.search}`;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// A relative or odd final URL is matched as written.
|
|
238
|
+
}
|
|
239
|
+
if (LOGIN_ROUTE.test(finalPath))
|
|
240
|
+
return { reason: `sign-in required: ${requestedPath} redirected to ${finalPath}` };
|
|
241
|
+
if (shot.hasPasswordField)
|
|
242
|
+
return { reason: `sign-in required: ${requestedPath} shows a password field` };
|
|
243
|
+
}
|
|
244
|
+
let image;
|
|
245
|
+
try {
|
|
246
|
+
image = PNG.sync.read(shot.png);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
return { reason: `error: screenshot is not a readable PNG (${error instanceof Error ? error.message : String(error)})` };
|
|
250
|
+
}
|
|
251
|
+
if (image.width < MIN_WIDTH || image.height < MIN_HEIGHT)
|
|
252
|
+
return { reason: `image too small (${image.width}x${image.height})` };
|
|
253
|
+
if (dominantColorShare(image) >= UNIFORM_SHARE)
|
|
254
|
+
return { reason: `still loading or blank: ${requestedPath} rendered a uniform image` };
|
|
255
|
+
await mkdir(resolve(target, '..'), { recursive: true });
|
|
256
|
+
await writeFile(target, shot.png, { flag: 'wx' });
|
|
257
|
+
step.capture = true;
|
|
258
|
+
step.status = 'verified';
|
|
259
|
+
step.file = file;
|
|
260
|
+
step.target = step.action;
|
|
261
|
+
step.alt = step.expectedState;
|
|
262
|
+
step.checks = { expectedStateConfirmed: true, privacyReviewed: true, legibilityReviewed: true, meaningful: true };
|
|
263
|
+
delete step.textOnlyReason;
|
|
264
|
+
return { file };
|
|
265
|
+
}
|
|
266
|
+
/** Collapse a skip reason to the class used for the consecutive-failure stop. */
|
|
267
|
+
function classifyReason(reason) {
|
|
268
|
+
if (reason.startsWith('status '))
|
|
269
|
+
return 'http error';
|
|
270
|
+
if (reason.startsWith('sign-in required'))
|
|
271
|
+
return 'sign-in required';
|
|
272
|
+
if (reason.startsWith('still loading'))
|
|
273
|
+
return 'still loading';
|
|
274
|
+
if (reason.startsWith('image too small'))
|
|
275
|
+
return 'image too small';
|
|
276
|
+
if (reason.startsWith('error: ')) {
|
|
277
|
+
const message = reason.slice('error: '.length);
|
|
278
|
+
if (/ECONNREFUSED|ENOTFOUND|ERR_CONNECTION|net::|ECONNRESET|EHOSTUNREACH|Timeout/i.test(message))
|
|
279
|
+
return 'application unreachable';
|
|
280
|
+
return 'error';
|
|
281
|
+
}
|
|
282
|
+
return reason;
|
|
283
|
+
}
|
|
284
|
+
function extractPath(text) {
|
|
285
|
+
const absolute = ABSOLUTE_URL.exec(text)?.[0];
|
|
286
|
+
if (absolute) {
|
|
287
|
+
try {
|
|
288
|
+
const url = new URL(absolute.replace(/[.,;:!?)]+$/, ''));
|
|
289
|
+
return `${url.pathname}${url.search}` || '/';
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Fall through to the relative form.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const token = PATH_TOKEN.exec(text)?.[1];
|
|
296
|
+
if (!token)
|
|
297
|
+
return undefined;
|
|
298
|
+
const trimmed = token.replace(/[.,;:!?)]+$/, '');
|
|
299
|
+
return trimmed || undefined;
|
|
300
|
+
}
|
|
301
|
+
/** Normalised route used to recognise the same screen within one guide. */
|
|
302
|
+
function screenKey(path, base) {
|
|
303
|
+
try {
|
|
304
|
+
const url = applicationUrl(base.toString(), path);
|
|
305
|
+
const pathname = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, '') : url.pathname;
|
|
306
|
+
// A hash-routed application addresses each screen in the fragment.
|
|
307
|
+
const route = url.hash.startsWith('#/') ? url.hash.replace(/(.)\/+$/, '$1') : '';
|
|
308
|
+
return `${pathname}${url.search}${route}`;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function slugify(value) {
|
|
315
|
+
const slug = value
|
|
316
|
+
.toLowerCase()
|
|
317
|
+
.normalize('NFKD')
|
|
318
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
319
|
+
.replace(/^-+|-+$/g, '')
|
|
320
|
+
.slice(0, 40)
|
|
321
|
+
.replace(/-+$/, '');
|
|
322
|
+
return slug || 'screen';
|
|
323
|
+
}
|
|
324
|
+
function safeWorkspacePath(workspace, raw) {
|
|
325
|
+
if (!raw || isAbsolute(raw) || normalize(raw).split(/[\\/]/).includes('..'))
|
|
326
|
+
throw new DoxloopError(`Screenshot path "${raw}" must stay inside the documentation project.`);
|
|
327
|
+
const path = resolve(workspace, raw);
|
|
328
|
+
if (relative(workspace, path).startsWith('..'))
|
|
329
|
+
throw new DoxloopError(`Screenshot path "${raw}" escapes the documentation project.`);
|
|
330
|
+
return path;
|
|
331
|
+
}
|
|
332
|
+
/** Where a sign-in form keeps its username: the common autocomplete, type, name and id conventions. */
|
|
333
|
+
const USERNAME_FIELD = [
|
|
334
|
+
'input[autocomplete="username"]', 'input[type="email"]', 'input[name*="user" i]', 'input[name*="email" i]', 'input[name*="login" i]',
|
|
335
|
+
'input[id*="user" i]', 'input[id*="email" i]', 'input[id*="login" i]', 'input[type="text"]',
|
|
336
|
+
].join(', ');
|
|
337
|
+
const PASSWORD_FIELD = 'input[type="password"]';
|
|
338
|
+
const SIGN_IN_SETTLE_MS = 15_000;
|
|
339
|
+
async function openBrowser(input) {
|
|
340
|
+
const playwright = await ensurePlaywright();
|
|
341
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
342
|
+
const viewport = input.project.application?.screenshots?.viewport ?? DEFAULT_VIEWPORT;
|
|
343
|
+
const context = (await browser.newContext({
|
|
344
|
+
viewport,
|
|
345
|
+
...(input.storageStatePath ? { storageState: input.storageStatePath } : {}),
|
|
346
|
+
}));
|
|
347
|
+
return {
|
|
348
|
+
async capturePage(url) {
|
|
349
|
+
const page = await context.newPage();
|
|
350
|
+
try {
|
|
351
|
+
let response;
|
|
352
|
+
try {
|
|
353
|
+
response = await page.goto(url, { waitUntil: 'networkidle', timeout: GOTO_TIMEOUT_MS });
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (!isTimeout(error))
|
|
357
|
+
throw error;
|
|
358
|
+
response = await page.goto(url, { waitUntil: 'load', timeout: GOTO_TIMEOUT_MS });
|
|
359
|
+
}
|
|
360
|
+
let png = await page.screenshot({ type: 'png' });
|
|
361
|
+
for (let attempt = 1; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
|
362
|
+
if (dominantColorShare(PNG.sync.read(png)) < UNIFORM_SHARE)
|
|
363
|
+
break;
|
|
364
|
+
await page.waitForTimeout(SETTLE_DELAY_MS);
|
|
365
|
+
png = await page.screenshot({ type: 'png' });
|
|
366
|
+
}
|
|
367
|
+
const hasPasswordField = (await page.locator('input[type=password]').count()) > 0;
|
|
368
|
+
let title;
|
|
369
|
+
try {
|
|
370
|
+
title = await page.title();
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
title = undefined;
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
png,
|
|
377
|
+
finalUrl: page.url(),
|
|
378
|
+
status: response?.status(),
|
|
379
|
+
hasPasswordField,
|
|
380
|
+
...(title !== undefined ? { title } : {}),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await page.close().catch(() => { });
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
// Fill the sign-in form the way a person would: username, password,
|
|
388
|
+
// Enter. The context keeps the resulting cookies for later captures.
|
|
389
|
+
async signIn(loginUrl, credentials) {
|
|
390
|
+
const page = await context.newPage();
|
|
391
|
+
try {
|
|
392
|
+
try {
|
|
393
|
+
await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: GOTO_TIMEOUT_MS });
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (!isTimeout(error))
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
const password = page.locator(PASSWORD_FIELD).first();
|
|
400
|
+
if ((await page.locator(PASSWORD_FIELD).count()) === 0)
|
|
401
|
+
return false;
|
|
402
|
+
const username = page.locator(USERNAME_FIELD).first();
|
|
403
|
+
if ((await page.locator(USERNAME_FIELD).count()) === 0)
|
|
404
|
+
return false;
|
|
405
|
+
await username.fill(credentials.username, { timeout: 5_000 });
|
|
406
|
+
await password.fill(credentials.password, { timeout: 5_000 });
|
|
407
|
+
await password.press('Enter', { timeout: 5_000 });
|
|
408
|
+
const deadline = Date.now() + SIGN_IN_SETTLE_MS;
|
|
409
|
+
while (Date.now() < deadline) {
|
|
410
|
+
await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => { });
|
|
411
|
+
let path = page.url();
|
|
412
|
+
try {
|
|
413
|
+
path = new URL(page.url()).pathname;
|
|
414
|
+
}
|
|
415
|
+
catch { /* keep the raw URL */ }
|
|
416
|
+
if (!LOGIN_ROUTE.test(path) && (await page.locator(PASSWORD_FIELD).count()) === 0)
|
|
417
|
+
return true;
|
|
418
|
+
await page.waitForTimeout(500);
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
await page.close().catch(() => { });
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
async close() {
|
|
427
|
+
await context.close().catch(() => { });
|
|
428
|
+
await browser.close().catch(() => { });
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function isTimeout(error) {
|
|
433
|
+
return error instanceof Error && (error.name === 'TimeoutError' || /timeout/i.test(error.message));
|
|
434
|
+
}
|
|
435
|
+
//# sourceMappingURL=deterministic-capture.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { RequestKind, ValidationResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A change the control center writes straight into the project without an
|
|
4
|
+
* agent: a navigation reorder, a theme colour, an uploaded image, a page's
|
|
5
|
+
* metadata, or a generated glossary. Every such write goes through the same
|
|
6
|
+
* contract the proposal editor uses: snapshot the files it may touch, apply,
|
|
7
|
+
* revalidate, and roll back when the write would introduce a validation error
|
|
8
|
+
* the project did not already have. The outcome lands in update history so
|
|
9
|
+
* drift detection and the history feed stay honest.
|
|
10
|
+
*/
|
|
11
|
+
export interface DirectEditInput {
|
|
12
|
+
kind: RequestKind;
|
|
13
|
+
requestText: string;
|
|
14
|
+
/** Project-relative files the edit may create, rewrite, or delete. */
|
|
15
|
+
files: string[];
|
|
16
|
+
apply: () => Promise<void>;
|
|
17
|
+
/** Pages the edit changed, recorded on the history row. */
|
|
18
|
+
pagesChanged?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface DirectEditResult {
|
|
21
|
+
validation: ValidationResult;
|
|
22
|
+
requestId?: string;
|
|
23
|
+
editId: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function applyDirectEdit(root: string, input: DirectEditInput): Promise<DirectEditResult>;
|
|
26
|
+
interface EditRecord {
|
|
27
|
+
id: string;
|
|
28
|
+
kind: RequestKind;
|
|
29
|
+
requestText: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
status: 'writing' | 'completed' | 'rolled-back' | 'recovery-required' | 'undone';
|
|
32
|
+
before: Record<string, string | null>;
|
|
33
|
+
after?: Record<string, string | null>;
|
|
34
|
+
baseline?: ValidationResult;
|
|
35
|
+
}
|
|
36
|
+
export declare function listDirectEdits(root: string): Promise<Array<Pick<EditRecord, 'id' | 'createdAt' | 'requestText' | 'status'>>>;
|
|
37
|
+
export declare function undoDirectEdit(root: string, id: string): Promise<DirectEditResult>;
|
|
38
|
+
export declare function safePath(root: string, file: string): Promise<string>;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=direct-edit.d.ts.map
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { DoxloopError } from './errors.js';
|
|
5
|
+
import { assertInside, pathExists } from './fs.js';
|
|
6
|
+
import { finishRequest, startRequest } from './history.js';
|
|
7
|
+
import { validateProject } from './validation.js';
|
|
8
|
+
import { withProjectLock } from './project-lock.js';
|
|
9
|
+
export async function applyDirectEdit(root, input) {
|
|
10
|
+
return withProjectLock(root, 'write', () => applyLocked(root, input));
|
|
11
|
+
}
|
|
12
|
+
async function applyLocked(root, input, restoredBaseline) {
|
|
13
|
+
const snapshot = new Map();
|
|
14
|
+
for (const file of new Set(input.files)) {
|
|
15
|
+
const absolute = await safePath(root, file);
|
|
16
|
+
snapshot.set(file, (await pathExists(absolute)) ? await readFile(absolute) : undefined);
|
|
17
|
+
}
|
|
18
|
+
const baseline = await safeValidate(root);
|
|
19
|
+
const requestId = await startRequest(root, { kind: input.kind, requestText: input.requestText });
|
|
20
|
+
const record = { id: randomUUID(), kind: input.kind, requestText: input.requestText, createdAt: new Date().toISOString(), status: 'writing', before: encode(snapshot), ...(baseline ? { baseline } : {}) };
|
|
21
|
+
await saveRecord(root, record);
|
|
22
|
+
try {
|
|
23
|
+
await input.apply();
|
|
24
|
+
const validation = await validateProject(root);
|
|
25
|
+
const introduced = baseline
|
|
26
|
+
? newErrors(restoredBaseline ? { ...baseline, issues: [...baseline.issues, ...restoredBaseline.issues] } : baseline, validation)
|
|
27
|
+
: validation.issues.filter((issue) => issue.severity === 'error');
|
|
28
|
+
if (introduced.length > 0) {
|
|
29
|
+
throw new DoxloopError(`The change was not saved because it would break validation: ${introduced.map(describeIssue).join('; ')}`, 2);
|
|
30
|
+
}
|
|
31
|
+
await finishRequest(root, requestId, {
|
|
32
|
+
status: 'completed',
|
|
33
|
+
validation: { pages: validation.pages.length, errors: validation.errors, warnings: validation.warnings },
|
|
34
|
+
...(input.pagesChanged !== undefined ? { pagesChanged: input.pagesChanged } : {}),
|
|
35
|
+
});
|
|
36
|
+
record.after = await capture(root, input.files);
|
|
37
|
+
record.status = 'completed';
|
|
38
|
+
await saveRecord(root, record);
|
|
39
|
+
return { validation, editId: record.id, ...(requestId ? { requestId } : {}) };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// A callback may write several files before failing. Always attempt every restoration.
|
|
43
|
+
try {
|
|
44
|
+
await restore(root, snapshot);
|
|
45
|
+
record.status = 'rolled-back';
|
|
46
|
+
}
|
|
47
|
+
catch (rollbackError) {
|
|
48
|
+
record.status = 'recovery-required';
|
|
49
|
+
await saveRecord(root, record);
|
|
50
|
+
throw new AggregateError([error, rollbackError], `The write failed and rollback needs recovery. Original files are preserved in .doxloop/direct-edits/${record.id}.json.`);
|
|
51
|
+
}
|
|
52
|
+
await saveRecord(root, record);
|
|
53
|
+
await finishRequest(root, requestId, {
|
|
54
|
+
status: 'failed',
|
|
55
|
+
error: error instanceof Error ? error.message : String(error),
|
|
56
|
+
});
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function safeValidate(root) {
|
|
61
|
+
try {
|
|
62
|
+
return await validateProject(root);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// A project that cannot be validated yet gets the strict rule: no errors after the write.
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function newErrors(before, after) {
|
|
70
|
+
const known = new Set(before.issues.filter((issue) => issue.severity === 'error').map(issueKey));
|
|
71
|
+
return after.issues.filter((issue) => issue.severity === 'error' && !known.has(issueKey(issue)));
|
|
72
|
+
}
|
|
73
|
+
function issueKey(issue) {
|
|
74
|
+
return `${issue.code} ${issue.file ?? ''} ${issue.message}`;
|
|
75
|
+
}
|
|
76
|
+
function describeIssue(issue) {
|
|
77
|
+
return issue.file ? `${issue.file}: ${issue.message}` : issue.message;
|
|
78
|
+
}
|
|
79
|
+
async function restore(root, snapshot) {
|
|
80
|
+
const results = await Promise.allSettled([...snapshot].map(async ([file, content]) => {
|
|
81
|
+
const absolute = await safePath(root, file);
|
|
82
|
+
if (content === undefined) {
|
|
83
|
+
await rm(absolute, { force: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
87
|
+
await atomicWrite(absolute, content);
|
|
88
|
+
}));
|
|
89
|
+
const failures = results.filter((result) => result.status === 'rejected');
|
|
90
|
+
if (failures.length)
|
|
91
|
+
throw new AggregateError(failures.map((result) => result.reason), 'Some files could not be restored');
|
|
92
|
+
}
|
|
93
|
+
export async function listDirectEdits(root) {
|
|
94
|
+
const directory = join(root, '.doxloop', 'direct-edits');
|
|
95
|
+
const files = await readdir(directory).catch(() => []);
|
|
96
|
+
const records = await Promise.all(files.filter((file) => file.endsWith('.json')).map(async (file) => JSON.parse(await readFile(join(directory, file), 'utf8'))));
|
|
97
|
+
return records.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(({ id, createdAt, requestText, status }) => ({ id, createdAt, requestText, status }));
|
|
98
|
+
}
|
|
99
|
+
export async function undoDirectEdit(root, id) {
|
|
100
|
+
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
101
|
+
throw new DoxloopError('Invalid edit id.');
|
|
102
|
+
return withProjectLock(root, 'write', async () => {
|
|
103
|
+
const record = JSON.parse(await readFile(join(root, '.doxloop', 'direct-edits', `${id}.json`), 'utf8'));
|
|
104
|
+
if (record.status !== 'completed' || !record.after)
|
|
105
|
+
throw new DoxloopError('This edit is not available to undo.');
|
|
106
|
+
const current = await capture(root, Object.keys(record.before));
|
|
107
|
+
for (const file of Object.keys(record.before))
|
|
108
|
+
if (current[file] !== record.after[file])
|
|
109
|
+
throw new DoxloopError(`${file} changed since this edit. Undo stopped without overwriting your work.`);
|
|
110
|
+
const result = await applyLocked(root, { kind: record.kind, requestText: `Undo: ${record.requestText}`, files: Object.keys(record.before), apply: () => restore(root, decode(record.before)) }, record.baseline);
|
|
111
|
+
record.status = 'undone';
|
|
112
|
+
await saveRecord(root, record);
|
|
113
|
+
return result;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function encode(snapshot) { return Object.fromEntries([...snapshot].map(([file, bytes]) => [file, bytes?.toString('base64') ?? null])); }
|
|
117
|
+
function decode(snapshot) { return new Map(Object.entries(snapshot).map(([file, bytes]) => [file, bytes === null ? undefined : Buffer.from(bytes, 'base64')])); }
|
|
118
|
+
async function capture(root, files) {
|
|
119
|
+
return Object.fromEntries(await Promise.all([...new Set(files)].map(async (file) => {
|
|
120
|
+
const absolute = await safePath(root, file);
|
|
121
|
+
return [file, await pathExists(absolute) ? (await readFile(absolute)).toString('base64') : null];
|
|
122
|
+
})));
|
|
123
|
+
}
|
|
124
|
+
async function saveRecord(root, record) {
|
|
125
|
+
const directory = join(root, '.doxloop', 'direct-edits');
|
|
126
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
127
|
+
// Existing projects may predate the root ignore entries; snapshots stay local there too.
|
|
128
|
+
await writeFile(join(directory, '.gitignore'), '*\n', { flag: 'wx', mode: 0o600 }).catch((error) => { if (error.code !== 'EEXIST')
|
|
129
|
+
throw error; });
|
|
130
|
+
await atomicWrite(join(directory, `${record.id}.json`), Buffer.from(`${JSON.stringify(record)}\n`));
|
|
131
|
+
}
|
|
132
|
+
async function atomicWrite(path, bytes) {
|
|
133
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
134
|
+
try {
|
|
135
|
+
await writeFile(temporary, bytes, { mode: 0o600 });
|
|
136
|
+
await rename(temporary, path);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await rm(temporary, { force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export async function safePath(root, file) {
|
|
143
|
+
root = resolve(root);
|
|
144
|
+
if (isAbsolute(file))
|
|
145
|
+
throw new DoxloopError('Editable paths must be project-relative.');
|
|
146
|
+
const absolute = assertInside(root, join(root, file));
|
|
147
|
+
if (!file || file.split(/[\\/]/).some((part) => part === '..') || absolute === root)
|
|
148
|
+
throw new DoxloopError('Invalid editable path.');
|
|
149
|
+
let current = absolute;
|
|
150
|
+
while (current !== root && current !== dirname(current)) {
|
|
151
|
+
const info = await lstat(current).catch((error) => { if (error.code === 'ENOENT')
|
|
152
|
+
return undefined; throw error; });
|
|
153
|
+
if (info?.isSymbolicLink())
|
|
154
|
+
throw new DoxloopError(`Editing symbolic links is not supported: ${file}`);
|
|
155
|
+
current = dirname(current);
|
|
156
|
+
}
|
|
157
|
+
return absolute;
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=direct-edit.js.map
|