@khoavu882/doflow 0.0.0-stage → 2.0.0-beta.4
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/LICENSE +21 -0
- package/README.md +132 -2
- package/bin/doflow.js +44 -0
- package/core/.antigravity-plugin/hooks.json +39 -0
- package/core/.antigravity-plugin/mcp_config.json +18 -0
- package/core/.antigravity-plugin/plugin.json +31 -0
- package/core/.claude-plugin/marketplace.json +20 -0
- package/core/.claude-plugin/plugin.json +21 -0
- package/core/.codex-plugin/marketplace.json +20 -0
- package/core/.codex-plugin/plugin.json +35 -0
- package/core/.plugin/plugin.json +19 -0
- package/core/harnesses/antigravity/hooks/pre-implementation-gate.sh +9 -0
- package/core/harnesses/antigravity/hooks/stop-check.sh +10 -0
- package/core/harnesses/claude/hooks/lib.sh +304 -0
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +20 -0
- package/core/harnesses/claude/hooks/permission-audit.sh +47 -0
- package/core/harnesses/claude/hooks/post-compact.sh +74 -0
- package/core/harnesses/claude/hooks/post-edit-lint.sh +36 -0
- package/core/harnesses/claude/hooks/post-tool-failure.sh +47 -0
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +20 -0
- package/core/harnesses/claude/hooks/pre-compact.sh +43 -0
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +21 -0
- package/core/harnesses/claude/hooks/session-end.sh +89 -0
- package/core/harnesses/claude/hooks/session-start.sh +5 -0
- package/core/harnesses/claude/hooks/skill-config-audit.sh +39 -0
- package/core/harnesses/claude/hooks/stop-check.sh +5 -0
- package/core/harnesses/claude/hooks/subagent-audit.sh +7 -0
- package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -0
- package/core/harnesses/claude/settings/keybindings.json +20 -0
- package/core/harnesses/claude/settings/settings.json +201 -0
- package/core/harnesses/claude/statusline/doflow-statusline.sh +49 -0
- package/core/harnesses/codex/agents/core-implementer.toml +7 -0
- package/core/harnesses/codex/agents/quality-guardian.toml +8 -0
- package/core/harnesses/codex/agents/research-writer.toml +8 -0
- package/core/harnesses/codex/agents/spec-analyst.toml +8 -0
- package/core/harnesses/codex/agents/system-architect.toml +9 -0
- package/core/harnesses/codex/config/config.toml +10 -0
- package/core/harnesses/codex/hooks/hooks.json +122 -0
- package/core/harnesses/codex/hooks/lib.sh +335 -0
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +18 -0
- package/core/harnesses/codex/hooks/post-edit-lint.impl.sh +52 -0
- package/core/harnesses/codex/hooks/post-edit-lint.sh +6 -0
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +18 -0
- package/core/harnesses/codex/hooks/pre-compact.impl.sh +46 -0
- package/core/harnesses/codex/hooks/pre-compact.sh +9 -0
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +19 -0
- package/core/harnesses/codex/hooks/session-end.impl.sh +92 -0
- package/core/harnesses/codex/hooks/session-end.sh +5 -0
- package/core/harnesses/codex/hooks/session-start.sh +5 -0
- package/core/harnesses/codex/hooks/stop-check.sh +8 -0
- package/core/harnesses/codex/hooks/subagent-audit.sh +8 -0
- package/core/harnesses/codex/hooks/user-prompt-submit.sh +6 -0
- package/core/harnesses/gemini/hooks/hooks.json +77 -0
- package/core/harnesses/gemini/hooks/lib.sh +302 -0
- package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +21 -0
- package/core/harnesses/gemini/hooks/post-edit-lint.sh +39 -0
- package/core/harnesses/gemini/hooks/pre-bash-guard.sh +21 -0
- package/core/harnesses/gemini/hooks/pre-compact.sh +38 -0
- package/core/harnesses/gemini/hooks/pre-implement-gate.sh +22 -0
- package/core/harnesses/gemini/hooks/session-end.impl.sh +89 -0
- package/core/harnesses/gemini/hooks/session-end.sh +5 -0
- package/core/harnesses/gemini/hooks/session-start.sh +5 -0
- package/core/harnesses/gemini/settings/adapter-defaults.json +4 -0
- package/core/harnesses/kiro/agents/doflow.json +58 -0
- package/core/harnesses/kiro/hooks/doflow.json +53 -0
- package/core/harnesses/kiro/hooks/lib.sh +324 -0
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +9 -0
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +9 -0
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +9 -0
- package/core/harnesses/kiro/hooks/session-start.sh +9 -0
- package/core/harnesses/kiro/hooks/stop-check.sh +43 -0
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +39 -0
- package/core/harnesses/shared/hooks/adapters/antigravity.js +111 -0
- package/core/harnesses/shared/hooks/adapters/arg-fields.js +19 -0
- package/core/harnesses/shared/hooks/adapters/gemini.js +114 -0
- package/core/harnesses/shared/hooks/hooks.json +47 -0
- package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +91 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +97 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +29 -0
- package/core/harnesses/shared/hooks/policies/lib.sh +501 -0
- package/core/harnesses/shared/hooks/policies/mcp-policy.conf +21 -0
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +91 -0
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +328 -0
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +496 -0
- package/core/harnesses/shared/hooks/policies/prompt-nudge.jq +131 -0
- package/core/harnesses/shared/hooks/policies/session-context.sh +141 -0
- package/core/harnesses/shared/hooks/policies/stop-check.sh +264 -0
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +46 -0
- package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +235 -0
- package/core/harnesses/shared/hooks/stream-hook-runner.js +590 -0
- package/core/harnesses/shared/locator/doflow-run +87 -0
- package/core/registry/advice.json +101 -0
- package/core/registry/assets.json +792 -0
- package/core/registry/capabilities.json +120 -0
- package/core/registry/contracts.json +189 -0
- package/core/registry/external-tools.json +191 -0
- package/core/registry/harnesses.json +2062 -0
- package/core/registry/lifecycle.json +231 -0
- package/core/registry/mcp.json +18 -0
- package/core/registry/models.json +79 -0
- package/core/registry/readiness-templates.json +118 -0
- package/core/registry/routes.json +47 -0
- package/core/registry/storage.json +1061 -0
- package/core/registry/verification.json +258 -0
- package/core/registry/workflows.json +790 -0
- package/core/shared/agent-specs/core-implementer.md +73 -0
- package/core/shared/agent-specs/quality-guardian.md +28 -0
- package/core/shared/agent-specs/research-writer.md +31 -0
- package/core/shared/agent-specs/spec-analyst.md +33 -0
- package/core/shared/agent-specs/system-architect.md +29 -0
- package/core/shared/guidance/DOFLOW_CORE.md +8 -0
- package/core/shared/guidance/FLAGS.md +25 -0
- package/core/shared/guidance/PRINCIPLES.md +61 -0
- package/core/shared/guidance/VERSION +1 -0
- package/core/shared/guidance/mcp/MCP_Context7.md +18 -0
- package/core/shared/guidance/mcp/MCP_Sequential.md +17 -0
- package/core/shared/guidance/modes/MODE_Brainstorming.md +46 -0
- package/core/shared/guidance/modes/MODE_DeepResearch.md +69 -0
- package/core/shared/guidance/modes/MODE_Introspection.md +33 -0
- package/core/shared/guidance/modes/MODE_Orchestration.md +57 -0
- package/core/shared/guidance/modes/MODE_Task_Management.md +74 -0
- package/core/shared/guidance/pointers/claude-gemini.md +2 -0
- package/core/shared/guidance/pointers/codex.md +6 -0
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +373 -0
- package/core/shared/guidance/references/ARTIFACT_VOICE.md +104 -0
- package/core/shared/guidance/references/CONSTITUTION_BASE.md +47 -0
- package/core/shared/guidance/references/DIAGRAM_GUIDE.md +161 -0
- package/core/shared/guidance/references/DOFLOW_CHAIN.md +87 -0
- package/core/shared/guidance/references/EVIDENCE_LEDGER.md +93 -0
- package/core/shared/guidance/references/MODEL_SELECTION.md +74 -0
- package/core/shared/guidance/references/ORCHESTRATION.md +36 -0
- package/core/shared/guidance/references/RESEARCH_CONFIG.md +21 -0
- package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +81 -0
- package/core/shared/guidance/rules/RULE_01_SAFETY.md +25 -0
- package/core/shared/guidance/rules/RULE_02_WORKFLOW.md +23 -0
- package/core/shared/guidance/rules/RULE_03_QUALITY.md +14 -0
- package/core/shared/guidance/rules/RULE_04_QUESTIONS.md +46 -0
- package/core/shared/scripts/doflow/assets/mermaid-theme.json +46 -0
- package/core/shared/scripts/doflow/bash/do-exec-paths.sh +167 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +546 -0
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +208 -0
- package/core/shared/scripts/doflow/bash/do-paths.sh +347 -0
- package/core/shared/scripts/doflow/bash/do-prereqs.sh +66 -0
- package/core/shared/scripts/doflow/bash/do-review-package.sh +127 -0
- package/core/shared/scripts/doflow/bash/do-task-brief.sh +698 -0
- package/core/shared/scripts/doflow/bash/render-audit.sh +224 -0
- package/core/shared/scripts/doflow/bash/render-diagrams.sh +177 -0
- package/core/shared/scripts/doflow/bash/render-puml.sh +223 -0
- package/core/shared/scripts/doflow/bash/sync-context.sh +66 -0
- package/core/shared/scripts/doflow/bash/validate-artifacts.sh +526 -0
- package/core/shared/scripts/doflow/bin/doflow-run +592 -0
- package/core/shared/skills/do/SKILL.md +128 -0
- package/core/shared/skills/do/references/estimation.md +13 -0
- package/core/shared/skills/do/references/pm_routing.md +25 -0
- package/core/shared/skills/do/references/tool_matrix.md +52 -0
- package/core/shared/skills/do-brainstorm/SKILL.md +252 -0
- package/core/shared/skills/do-code-review/README.md +49 -0
- package/core/shared/skills/do-code-review/SKILL.md +333 -0
- package/core/shared/skills/do-code-review/assets/sample_c_clean.c +71 -0
- package/core/shared/skills/do-code-review/assets/sample_c_smells.c +66 -0
- package/core/shared/skills/do-code-review/assets/sample_cpp_shift.cpp +19 -0
- package/core/shared/skills/do-code-review/assets/sample_csharp_clean.cs +100 -0
- package/core/shared/skills/do-code-review/assets/sample_csharp_smells.cs +78 -0
- package/core/shared/skills/do-code-review/assets/sample_java_clean.java +55 -0
- package/core/shared/skills/do-code-review/assets/sample_java_smells.java +56 -0
- package/core/shared/skills/do-code-review/assets/sample_json_clean.json +7 -0
- package/core/shared/skills/do-code-review/assets/sample_markdown_clean.md +30 -0
- package/core/shared/skills/do-code-review/assets/sample_markdown_smells.md +29 -0
- package/core/shared/skills/do-code-review/assets/sample_shell_heredoc.sh +38 -0
- package/core/shared/skills/do-code-review/assets/sample_shell_smells.sh +15 -0
- package/core/shared/skills/do-code-review/assets/sample_typescript_strings.ts +36 -0
- package/core/shared/skills/do-code-review/assets/sample_yaml_smells.yaml +6 -0
- package/core/shared/skills/do-code-review/content-types/config.md +83 -0
- package/core/shared/skills/do-code-review/content-types/markdown.md +132 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_c_clean_quality.json +79 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_c_smells_quality.json +169 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_cpp_shift_quality.json +49 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_clean_quality.json +149 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_csharp_smells_quality.json +157 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_java_clean_quality.json +67 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_java_smells_quality.json +97 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_json_clean_quality.json +33 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_clean_quality.json +1 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_markdown_smells_quality.json +16 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_shell_heredoc_quality.json +54 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_shell_smells_quality.json +91 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_typescript_strings_quality.json +60 -0
- package/core/shared/skills/do-code-review/expected_outputs/sample_yaml_smells_quality.json +46 -0
- package/core/shared/skills/do-code-review/languages/c.md +103 -0
- package/core/shared/skills/do-code-review/languages/cpp.md +103 -0
- package/core/shared/skills/do-code-review/languages/csharp.md +97 -0
- package/core/shared/skills/do-code-review/languages/dart.md +108 -0
- package/core/shared/skills/do-code-review/languages/go.md +92 -0
- package/core/shared/skills/do-code-review/languages/java.md +103 -0
- package/core/shared/skills/do-code-review/languages/kotlin.md +88 -0
- package/core/shared/skills/do-code-review/languages/php.md +109 -0
- package/core/shared/skills/do-code-review/languages/python.md +94 -0
- package/core/shared/skills/do-code-review/languages/ruby.md +105 -0
- package/core/shared/skills/do-code-review/languages/rust.md +99 -0
- package/core/shared/skills/do-code-review/languages/shell.md +90 -0
- package/core/shared/skills/do-code-review/languages/swift.md +88 -0
- package/core/shared/skills/do-code-review/languages/typescript.md +97 -0
- package/core/shared/skills/do-code-review/review-policy.json +47 -0
- package/core/shared/skills/do-code-review/rules/universal.md +49 -0
- package/core/shared/skills/do-code-review/scripts/code_quality_checker.py +1696 -0
- package/core/shared/skills/do-code-review/scripts/doc_quality_checker.py +532 -0
- package/core/shared/skills/do-code-review/scripts/pr_analyzer.py +620 -0
- package/core/shared/skills/do-code-review/scripts/review_report_generator.py +505 -0
- package/core/shared/skills/do-constitution/SKILL.md +72 -0
- package/core/shared/skills/do-design/SKILL.md +212 -0
- package/core/shared/skills/do-diagnose/SKILL.md +156 -0
- package/core/shared/skills/do-diagnose/references/code_audit.md +15 -0
- package/core/shared/skills/do-diagnose/references/refactoring.md +12 -0
- package/core/shared/skills/do-diagnose/references/root_cause.md +17 -0
- package/core/shared/skills/do-document/SKILL.md +81 -0
- package/core/shared/skills/do-document/references/api-reference.md +42 -0
- package/core/shared/skills/do-document/references/deep_research.md +18 -0
- package/core/shared/skills/do-document/references/embedded-research.md +11 -0
- package/core/shared/skills/do-document/references/implementation-flow.md +105 -0
- package/core/shared/skills/do-document/references/index_generator.md +9 -0
- package/core/shared/skills/do-document/references/user-guide.md +22 -0
- package/core/shared/skills/do-execute-plan/SKILL.md +261 -0
- package/core/shared/skills/do-execute-plan/references/handoff_record.md +57 -0
- package/core/shared/skills/do-execute-plan/references/parallel_dispatch.md +92 -0
- package/core/shared/skills/do-execute-plan/references/readiness_gate.md +155 -0
- package/core/shared/skills/do-execute-plan/references/run_position.md +63 -0
- package/core/shared/skills/do-execute-plan/references/scaffold.md +441 -0
- package/core/shared/skills/do-execute-plan/references/task_attempts.md +41 -0
- package/core/shared/skills/do-flow/SKILL.md +186 -0
- package/core/shared/skills/do-flow/references/task_classes.md +134 -0
- package/core/shared/skills/do-git/SKILL.md +115 -0
- package/core/shared/skills/do-git/references/hotfix-ritual.md +44 -0
- package/core/shared/skills/do-git/references/lifecycle-policy.md +54 -0
- package/core/shared/skills/do-git/references/release-ritual.md +64 -0
- package/core/shared/skills/do-implement/SKILL.md +222 -0
- package/core/shared/skills/do-plan/SKILL.md +219 -0
- package/core/shared/skills/do-test/SKILL.md +113 -0
- package/core/shared/templates/doflow/constitution-template.md +28 -0
- package/core/shared/templates/doflow/data-model-template.md +169 -0
- package/core/shared/templates/doflow/design-template.md +193 -0
- package/core/shared/templates/doflow/external-contract-template.md +35 -0
- package/core/shared/templates/doflow/intent-template.md +45 -0
- package/core/shared/templates/doflow/plan-template.md +151 -0
- package/core/shared/templates/doflow/question-log-template.md +51 -0
- package/core/shared/templates/doflow/requirement-template.md +126 -0
- package/core/shared/templates/doflow/specs-template.md +55 -0
- package/core/shared/templates/doflow/state-template.md +59 -0
- package/core/shared/workflows/do-flow-chain.md +17 -0
- package/package.json +48 -4
- package/src/adapters/antigravity/index.js +631 -0
- package/src/adapters/claude/index.js +548 -0
- package/src/adapters/claude/mcp.js +94 -0
- package/src/adapters/codex/agents.js +208 -0
- package/src/adapters/codex/config.js +272 -0
- package/src/adapters/codex/hooks.js +203 -0
- package/src/adapters/codex/index.js +707 -0
- package/src/adapters/codex/mcp.js +271 -0
- package/src/adapters/codex/native-projection.js +34 -0
- package/src/adapters/copilot/index.js +413 -0
- package/src/adapters/copy-tree.js +620 -0
- package/src/adapters/gemini/hooks.js +210 -0
- package/src/adapters/gemini/index.js +390 -0
- package/src/adapters/hook-commands.js +60 -0
- package/src/adapters/index.js +106 -0
- package/src/adapters/kiro/index.js +310 -0
- package/src/adapters/mcp-entries.js +331 -0
- package/src/adapters/opencode/index.js +466 -0
- package/src/adapters/pi/index.js +279 -0
- package/src/adapters/pi/json-members.js +185 -0
- package/src/adapters/pi/mcp.js +373 -0
- package/src/adapters/resolve-source.js +19 -0
- package/src/cli/commands/install.js +218 -0
- package/src/cli/commands/list-backups.js +21 -0
- package/src/cli/commands/reconcile.js +120 -0
- package/src/cli/commands/remove.js +82 -0
- package/src/cli/commands/rollback.js +172 -0
- package/src/cli/commands/self-update.js +33 -0
- package/src/cli/commands/status.js +138 -0
- package/src/cli/commands/tools.js +92 -0
- package/src/cli/commands/update.js +162 -0
- package/src/cli/index.js +568 -0
- package/src/cli/runtime-commands.js +281 -0
- package/src/cli/shared.js +218 -0
- package/src/helper/bash-helper.js +34 -0
- package/src/helper/git.js +24 -0
- package/src/helper/guardrails.js +49 -0
- package/src/helper/harness-paths.js +140 -0
- package/src/helper/marker-merge.js +116 -0
- package/src/helper/prompt.js +61 -0
- package/src/helper/repo-root.js +29 -0
- package/src/helper/settings-merge.js +151 -0
- package/src/helper/settings-scope.js +33 -0
- package/src/helper/toml.js +244 -0
- package/src/install/backup-set.js +149 -0
- package/src/install/backup.js +724 -0
- package/src/install/context.js +58 -0
- package/src/install/manifest.js +145 -0
- package/src/install/mcp.js +254 -0
- package/src/install/paths.js +84 -0
- package/src/install/targets.js +90 -0
- package/src/install/tool-lifecycle.js +151 -0
- package/src/lifecycle/bash-availability.js +25 -0
- package/src/lifecycle/index.js +812 -0
- package/src/lifecycle/mcp-index.js +28 -0
- package/src/lifecycle/policies.js +73 -0
- package/src/lifecycle/view.js +175 -0
- package/src/registry/index.js +611 -0
- package/src/release/build-package.js +91 -0
- package/src/release/changelog-section.js +70 -0
- package/src/release/sync-plugin-versions.js +66 -0
- package/src/runtime/advice/cli.js +283 -0
- package/src/runtime/advice/client.js +324 -0
- package/src/runtime/advice/config.js +189 -0
- package/src/runtime/advice/doctor.js +169 -0
- package/src/runtime/advice/endpoint-guard.js +134 -0
- package/src/runtime/advice/kinds.js +154 -0
- package/src/runtime/advice/providers/ollama-systemone.js +209 -0
- package/src/runtime/advice/record.js +166 -0
- package/src/runtime/advice/registry.js +170 -0
- package/src/runtime/advice/strength.js +66 -0
- package/src/runtime/brief-tools.js +342 -0
- package/src/runtime/c4-project.js +371 -0
- package/src/runtime/capability-router.js +484 -0
- package/src/runtime/checkouts.js +170 -0
- package/src/runtime/claims.js +468 -0
- package/src/runtime/cli-result.js +55 -0
- package/src/runtime/cli.js +678 -0
- package/src/runtime/command-detect.js +516 -0
- package/src/runtime/context-pack.js +492 -0
- package/src/runtime/decision-register.js +493 -0
- package/src/runtime/evidence-ledger.js +338 -0
- package/src/runtime/failure/capture.js +272 -0
- package/src/runtime/failure/classifier.js +68 -0
- package/src/runtime/failure/cli.js +178 -0
- package/src/runtime/failure/home.js +64 -0
- package/src/runtime/failure/store.js +226 -0
- package/src/runtime/feature-resolve.js +79 -0
- package/src/runtime/freshness.js +177 -0
- package/src/runtime/health.js +863 -0
- package/src/runtime/history-compactor.js +237 -0
- package/src/runtime/implementation-gate.js +321 -0
- package/src/runtime/inventory/coverage.js +218 -0
- package/src/runtime/inventory/identity.js +65 -0
- package/src/runtime/inventory/index.js +705 -0
- package/src/runtime/inventory/precedence.js +134 -0
- package/src/runtime/inventory/read-scopes.js +131 -0
- package/src/runtime/inventory/siblings.js +192 -0
- package/src/runtime/knowledge/chunker.js +124 -0
- package/src/runtime/knowledge/hybrid.js +149 -0
- package/src/runtime/knowledge/index-store.js +145 -0
- package/src/runtime/knowledge/retrieval.js +194 -0
- package/src/runtime/leak-scan.js +185 -0
- package/src/runtime/lifecycle/cli.js +276 -0
- package/src/runtime/lifecycle/event-store.js +340 -0
- package/src/runtime/lifecycle/fold.js +353 -0
- package/src/runtime/lifecycle/followup.js +432 -0
- package/src/runtime/lifecycle/goal.js +249 -0
- package/src/runtime/lifecycle/intent-writer.js +86 -0
- package/src/runtime/lifecycle/overview.js +292 -0
- package/src/runtime/lifecycle/release.js +241 -0
- package/src/runtime/lifecycle/report-store.js +221 -0
- package/src/runtime/lifecycle/retention.js +212 -0
- package/src/runtime/lifecycle/root.js +71 -0
- package/src/runtime/lifecycle/status.js +353 -0
- package/src/runtime/lifecycle/store-upkeep.js +393 -0
- package/src/runtime/locator-resolve.js +158 -0
- package/src/runtime/mask.js +210 -0
- package/src/runtime/mcp-consultations.js +412 -0
- package/src/runtime/mcp-reach.js +157 -0
- package/src/runtime/model-router.js +153 -0
- package/src/runtime/outcome.js +467 -0
- package/src/runtime/reach.js +148 -0
- package/src/runtime/readiness-record.js +150 -0
- package/src/runtime/readiness.js +435 -0
- package/src/runtime/recovery.js +282 -0
- package/src/runtime/research-request.js +231 -0
- package/src/runtime/retrieval-plan.js +578 -0
- package/src/runtime/scaffold/artifacts.js +217 -0
- package/src/runtime/scaffold/fingerprint.js +56 -0
- package/src/runtime/scaffold/generate.js +911 -0
- package/src/runtime/scaffold/languages.js +245 -0
- package/src/runtime/storage/cli.js +64 -0
- package/src/runtime/storage/engine.js +249 -0
- package/src/runtime/storage/registry.js +130 -0
- package/src/runtime/storage/roots.js +63 -0
- package/src/runtime/storage/rules.js +282 -0
- package/src/runtime/storage/upkeep.js +243 -0
- package/src/runtime/storage/work-status.js +326 -0
- package/src/runtime/task-attempts.js +415 -0
- package/src/runtime/task-classifier.js +550 -0
- package/src/runtime/task-scope.js +130 -0
- package/src/runtime/task-state.js +176 -0
- package/src/runtime/trace/indicators.js +235 -0
- package/src/runtime/trace/ledger.js +626 -0
- package/src/runtime/trace/render.js +76 -0
- package/src/runtime/trace/views.js +459 -0
- package/src/runtime/verification/contract-runner.js +341 -0
- package/src/runtime/verification/engine.js +1461 -0
- package/src/runtime/verification/registry.js +130 -0
- package/src/runtime/verification/scope-bound.js +418 -0
- package/src/runtime/verification/test-selection.js +172 -0
- package/src/runtime/workflow-engine.js +588 -0
- package/src/runtime/workflow-orchestrator.js +953 -0
- package/src/runtime/worktree.js +340 -0
- package/src/state/index.js +193 -0
- package/src/state/lockfile.js +136 -0
- package/src/state/run-lock.js +408 -0
- package/types/node-commonjs.d.ts +68 -0
- package/types/runtime.d.ts +91 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// backup.js — writing, listing, restoring and pruning backups. Backup id `<op>_YYYY-MM-DD_HH-MM-SS`;
|
|
3
|
+
// each backup dir carries its own `.manifest.json` (id/operation/timestamp/source_path/
|
|
4
|
+
// source_commit/type/tools_affected), distinct from the top-level install manifest in manifest.js.
|
|
5
|
+
//
|
|
6
|
+
// A backup (createFileBackup, format 2) is a per-file copy of exactly the files a run changes,
|
|
7
|
+
// `files/<rel>` for files under the scope root and `external/<k>/<name>` for files outside it, with
|
|
8
|
+
// a manifest that records for each file whether it existed. It is assembled in a dot-named temp
|
|
9
|
+
// directory and renamed into place, so a reader sees either no backup or a complete one.
|
|
10
|
+
//
|
|
11
|
+
// Backups from DoFlow 1.19 stay readable and restorable: a full backup is a tar.gz per tool, a
|
|
12
|
+
// partial backup a plain copy tree per tool. Nothing writes those layouts any more.
|
|
13
|
+
//
|
|
14
|
+
// TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): authored as `.ts`, run directly by Node
|
|
15
|
+
// native type-stripping — no build step, no runtime dependency. Body stays CommonJS so a `.js`
|
|
16
|
+
// caller's `require('../install/backup.js')` keeps working while the tree is still `.js`. The
|
|
17
|
+
// `./paths` require already carries `.ts` (paths migrated first); `../helper/git.js` is `.ts` too.
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const crypto = require('node:crypto');
|
|
22
|
+
const { execFileSync } = require('node:child_process');
|
|
23
|
+
const { sourceCommit: gitSourceCommit } = require('../helper/git.js');
|
|
24
|
+
const {
|
|
25
|
+
legacyBackupReadRoot, isLegacyBackupReadRoot, scopeRootFromCanonicalBackupRoot,
|
|
26
|
+
} = require('./paths.js');
|
|
27
|
+
const { validateLedger } = require('../state/index.js');
|
|
28
|
+
|
|
29
|
+
/** Where a listed/restorable backup was found. 'current' = the canonical `.doflow/backups` root the
|
|
30
|
+
* installer writes to; 'legacy' = the pre-7de6d5f `.claude/backups` root, readable only. Callers
|
|
31
|
+
* get this on every row so a legacy restore point is never presented as a current one. */
|
|
32
|
+
const BACKUP_ORIGIN_CURRENT = 'current';
|
|
33
|
+
const BACKUP_ORIGIN_LEGACY = 'legacy';
|
|
34
|
+
|
|
35
|
+
/** The roots a READ (list/restore) consults for a scope, canonical first, legacy second.
|
|
36
|
+
* Order is the precedence rule too: an id present in both resolves to the canonical copy. */
|
|
37
|
+
function backupReadRoots(backupRoot ) {
|
|
38
|
+
const roots = [{ root: path.resolve(backupRoot), origin: BACKUP_ORIGIN_CURRENT }];
|
|
39
|
+
const scopeRoot = scopeRootFromCanonicalBackupRoot(backupRoot);
|
|
40
|
+
if (scopeRoot) {
|
|
41
|
+
const legacy = legacyBackupReadRoot({ scopeRoot });
|
|
42
|
+
if (legacy !== roots[0].root) roots.push({ root: legacy, origin: BACKUP_ORIGIN_LEGACY });
|
|
43
|
+
}
|
|
44
|
+
return roots;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Writes and deletes are canonical-only. The legacy root holds restore points DoFlow no longer
|
|
48
|
+
* manages and may be a user's sole recovery material, so a mutating call aimed at it is a bug to
|
|
49
|
+
* surface, not a path to follow. */
|
|
50
|
+
function assertMutableBackupRoot(backupRoot , operation ) {
|
|
51
|
+
if (isLegacyBackupReadRoot(backupRoot)) {
|
|
52
|
+
throw new Error(`Refusing to ${operation} in the legacy backup root (read-only): ${backupRoot}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pad2(n ) { return String(n).padStart(2, '0'); }
|
|
57
|
+
|
|
58
|
+
/** `<op>_YYYY-MM-DD_HH-MM-SS`, using a caller-supplied Date (never `new Date()` internally — keeps this testable/deterministic). */
|
|
59
|
+
function backupId(op , date ) {
|
|
60
|
+
const y = date.getFullYear();
|
|
61
|
+
const mo = pad2(date.getMonth() + 1);
|
|
62
|
+
const d = pad2(date.getDate());
|
|
63
|
+
const h = pad2(date.getHours());
|
|
64
|
+
const mi = pad2(date.getMinutes());
|
|
65
|
+
const s = pad2(date.getSeconds());
|
|
66
|
+
return `${op}_${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const MANIFEST_FILE = '.manifest.json';
|
|
70
|
+
const OWNER_FILE = '.owner.json';
|
|
71
|
+
const TEMP_PREFIX = '.tmp-';
|
|
72
|
+
const STALE_TEMP_MS = 24 * 60 * 60 * 1000;
|
|
73
|
+
const MAX_ID_ATTEMPTS = 1000;
|
|
74
|
+
const RENAME_COLLISION_CODES = new Set(['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES']);
|
|
75
|
+
|
|
76
|
+
/** How many backups install and update keep per scope when `--prune` is not given. */
|
|
77
|
+
const DEFAULT_BACKUP_RETENTION = 3;
|
|
78
|
+
|
|
79
|
+
/** A backup could not be written completely. The caller stops before changing anything. */
|
|
80
|
+
class BackupError extends Error {
|
|
81
|
+
|
|
82
|
+
constructor(message ) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'BackupError';
|
|
85
|
+
this.code = 'backup-failed';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** POSIX path of `p` relative to `scopeRoot`, or null when `p` is not strictly inside it. */
|
|
90
|
+
function scopeRelative(scopeRoot , p ) {
|
|
91
|
+
const rel = path.relative(scopeRoot, p);
|
|
92
|
+
if (!rel || path.isAbsolute(rel) || rel.split(path.sep)[0] === '..') return null;
|
|
93
|
+
return rel.split(path.sep).join('/');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Manifest JSON: two-space indentation, except one compact line per `files` and `excluded` element. */
|
|
97
|
+
function serializeManifest(manifest ) {
|
|
98
|
+
const body = Object.entries(manifest).map(([key, value]) => {
|
|
99
|
+
if ((key === 'files' || key === 'excluded') && Array.isArray(value) && value.length > 0) {
|
|
100
|
+
return ` ${JSON.stringify(key)}: [\n${value.map((v) => ` ${JSON.stringify(v)}`).join(',\n')}\n ]`;
|
|
101
|
+
}
|
|
102
|
+
return ` ${JSON.stringify(key)}: ${JSON.stringify(value, null, 2).replace(/\n/g, '\n ')}`;
|
|
103
|
+
});
|
|
104
|
+
return `{\n${body.join(',\n')}\n}\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function errorCode(err ) { return err.code || err.message; }
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Copy the files of a backup set into `<backupRoot>/<id>/` (format 2). Returns null for an empty
|
|
111
|
+
* set, without touching the disk. Every failure removes the temp directory and throws BackupError,
|
|
112
|
+
* so a backup either exists complete, manifest included, or does not exist at all.
|
|
113
|
+
*/
|
|
114
|
+
function createFileBackup({
|
|
115
|
+
operation, set, backupRoot, repoRoot, sourceCommit, version, date, restores = null,
|
|
116
|
+
fsImpl = fs, randomHex = () => crypto.randomBytes(4).toString('hex'),
|
|
117
|
+
} ) {
|
|
118
|
+
assertMutableBackupRoot(backupRoot, 'create a backup');
|
|
119
|
+
if (set.entries.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
const base = backupId(operation, date);
|
|
122
|
+
const tmpDir = path.join(backupRoot, `${TEMP_PREFIX}${base}-${process.pid}-${randomHex()}`);
|
|
123
|
+
let tmpCreated = false;
|
|
124
|
+
const writeFailure = (err ) => new BackupError(`could not write ${backupRoot}: ${errorCode(err)}`);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
try {
|
|
128
|
+
sweepStaleTemps(backupRoot, { fsImpl });
|
|
129
|
+
fsImpl.mkdirSync(backupRoot, { recursive: true });
|
|
130
|
+
fsImpl.mkdirSync(tmpDir);
|
|
131
|
+
tmpCreated = true;
|
|
132
|
+
const owner = { pid: process.pid, hostname: os.hostname(), startedAt: date.toISOString() };
|
|
133
|
+
fsImpl.writeFileSync(path.join(tmpDir, OWNER_FILE), `${JSON.stringify(owner)}\n`, { flag: 'wx' });
|
|
134
|
+
} catch (err) { throw writeFailure(err); }
|
|
135
|
+
|
|
136
|
+
const files = [];
|
|
137
|
+
const summary = { files: 0, existed: 0, absent: 0, bytes: 0 };
|
|
138
|
+
let external = 0;
|
|
139
|
+
for (const entry of set.entries) {
|
|
140
|
+
const record = entry.rel !== null ? { path: entry.rel } : { path: entry.path, outside: true };
|
|
141
|
+
let stat = null;
|
|
142
|
+
try {
|
|
143
|
+
stat = fsImpl.statSync(entry.path);
|
|
144
|
+
} catch (err ) {
|
|
145
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
|
|
146
|
+
throw new BackupError(`could not read ${entry.path}: ${errorCode(err)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
summary.files += 1;
|
|
150
|
+
if (!stat) {
|
|
151
|
+
record.existed = false;
|
|
152
|
+
summary.absent += 1;
|
|
153
|
+
} else if (stat.isFile()) {
|
|
154
|
+
const stored = entry.rel !== null
|
|
155
|
+
? `files/${entry.rel}`
|
|
156
|
+
: `external/${external++}/${path.basename(entry.path)}`;
|
|
157
|
+
const dest = path.join(tmpDir, ...stored.split('/'));
|
|
158
|
+
try {
|
|
159
|
+
fsImpl.mkdirSync(path.dirname(dest), { recursive: true });
|
|
160
|
+
fsImpl.copyFileSync(entry.path, dest);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw new BackupError(`could not copy ${entry.path}: ${errorCode(err)}`);
|
|
163
|
+
}
|
|
164
|
+
Object.assign(record, { existed: true, size: stat.size, mode: stat.mode & 0o7777, stored });
|
|
165
|
+
summary.existed += 1;
|
|
166
|
+
summary.bytes += stat.size;
|
|
167
|
+
} else {
|
|
168
|
+
Object.assign(record, { existed: true, kind: 'other' });
|
|
169
|
+
summary.existed += 1;
|
|
170
|
+
}
|
|
171
|
+
record.harnesses = entry.harnesses;
|
|
172
|
+
files.push(record);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const manifest = {
|
|
176
|
+
id: base,
|
|
177
|
+
operation,
|
|
178
|
+
timestamp: date.toISOString(),
|
|
179
|
+
source_path: repoRoot,
|
|
180
|
+
source_commit: sourceCommit ?? gitSourceCommit(repoRoot),
|
|
181
|
+
type: 'files',
|
|
182
|
+
tools_affected: [...new Set(set.entries.flatMap((e ) => e.harnesses))].sort(),
|
|
183
|
+
format: 2,
|
|
184
|
+
doflow_version: version,
|
|
185
|
+
scope: set.scope,
|
|
186
|
+
scope_root: set.scopeRoot,
|
|
187
|
+
restores,
|
|
188
|
+
summary,
|
|
189
|
+
excluded: set.excluded.map((x ) => {
|
|
190
|
+
const rel = scopeRelative(set.scopeRoot, x.path);
|
|
191
|
+
return rel !== null ? { path: rel, reason: x.reason } : { path: x.path, outside: true, reason: x.reason };
|
|
192
|
+
}),
|
|
193
|
+
files,
|
|
194
|
+
};
|
|
195
|
+
const manifestPath = path.join(tmpDir, MANIFEST_FILE);
|
|
196
|
+
try {
|
|
197
|
+
fsImpl.unlinkSync(path.join(tmpDir, OWNER_FILE));
|
|
198
|
+
fsImpl.writeFileSync(manifestPath, serializeManifest(manifest), { flag: 'wx' });
|
|
199
|
+
} catch (err) { throw writeFailure(err); }
|
|
200
|
+
|
|
201
|
+
// Two runs in the same second share a base id; the loser of the rename takes the next suffix.
|
|
202
|
+
for (let n = 1; n <= MAX_ID_ATTEMPTS; n += 1) {
|
|
203
|
+
const candidate = n === 1 ? base : `${base}-${n}`;
|
|
204
|
+
const dir = path.join(backupRoot, candidate);
|
|
205
|
+
if (fsImpl.existsSync(dir)) continue;
|
|
206
|
+
try {
|
|
207
|
+
if (manifest.id !== candidate) {
|
|
208
|
+
manifest.id = candidate;
|
|
209
|
+
fsImpl.writeFileSync(manifestPath, serializeManifest(manifest));
|
|
210
|
+
}
|
|
211
|
+
fsImpl.renameSync(tmpDir, dir);
|
|
212
|
+
} catch (err ) {
|
|
213
|
+
if (RENAME_COLLISION_CODES.has(err.code) && fsImpl.existsSync(dir)) continue;
|
|
214
|
+
throw writeFailure(err);
|
|
215
|
+
}
|
|
216
|
+
tmpCreated = false;
|
|
217
|
+
return { id: candidate, dir, ...summary };
|
|
218
|
+
}
|
|
219
|
+
throw new BackupError(`no free backup id after ${MAX_ID_ATTEMPTS} attempts`);
|
|
220
|
+
} finally {
|
|
221
|
+
if (tmpCreated) {
|
|
222
|
+
try { fsImpl.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function defaultIsAlive(pid ) {
|
|
228
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
229
|
+
try {
|
|
230
|
+
process.kill(pid, 0);
|
|
231
|
+
return true;
|
|
232
|
+
} catch (err ) {
|
|
233
|
+
return err.code === 'EPERM';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isStaleTemp(dir , { now, fsImpl, isAlive, hostname } ) {
|
|
238
|
+
let owner = null;
|
|
239
|
+
try { owner = JSON.parse(fsImpl.readFileSync(path.join(dir, OWNER_FILE), 'utf8')); } catch { /* no owner */ }
|
|
240
|
+
if (!owner || typeof owner !== 'object') {
|
|
241
|
+
try { return now - fsImpl.lstatSync(dir).mtimeMs > STALE_TEMP_MS; } catch { return false; }
|
|
242
|
+
}
|
|
243
|
+
if (owner.hostname === hostname && !isAlive(owner.pid)) return true;
|
|
244
|
+
return now - Date.parse(owner.startedAt) > STALE_TEMP_MS;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Remove `.tmp-*` directories left in `backupRoot` by runs that died: the owner process is gone on
|
|
249
|
+
* this host, or the temp is older than 24 hours. Returns how many were removed.
|
|
250
|
+
*/
|
|
251
|
+
function sweepStaleTemps(backupRoot , { now = Date.now(), fsImpl = fs, isAlive = defaultIsAlive, hostname = os.hostname() } = {}) {
|
|
252
|
+
assertMutableBackupRoot(backupRoot, 'clean up backups');
|
|
253
|
+
if (!fsImpl.existsSync(backupRoot)) return 0;
|
|
254
|
+
let removed = 0;
|
|
255
|
+
for (const e of fsImpl.readdirSync(backupRoot, { withFileTypes: true })) {
|
|
256
|
+
if (!e.isDirectory() || !e.name.startsWith(TEMP_PREFIX)) continue;
|
|
257
|
+
const dir = path.join(backupRoot, e.name);
|
|
258
|
+
if (!isStaleTemp(dir, { now, fsImpl, isAlive, hostname })) continue;
|
|
259
|
+
try {
|
|
260
|
+
fsImpl.rmSync(dir, { recursive: true, force: true });
|
|
261
|
+
removed += 1;
|
|
262
|
+
} catch { /* another run may hold or have removed it */ }
|
|
263
|
+
}
|
|
264
|
+
return removed;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Classify one backup directory by its manifest: 'format-2', 'newer' (a format this version does
|
|
269
|
+
* not know), 'full' or 'partial' (1.19, typed the way restoreBackup reads them), or 'incomplete'
|
|
270
|
+
* (no manifest, or one that does not parse). `manifest` is the parsed object, or null.
|
|
271
|
+
*/
|
|
272
|
+
function classifyBackupDir(dir , { fsImpl = fs } = {}) {
|
|
273
|
+
let manifest = null;
|
|
274
|
+
try { manifest = JSON.parse(fsImpl.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8')); } catch { /* incomplete */ }
|
|
275
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return { kind: 'incomplete', manifest: null };
|
|
276
|
+
const { format } = manifest;
|
|
277
|
+
if (format === 2) return { kind: 'format-2', manifest };
|
|
278
|
+
if (Number.isInteger(format) && format > 2) return { kind: 'newer', manifest };
|
|
279
|
+
if (format !== undefined && format !== null) return { kind: 'incomplete', manifest: null };
|
|
280
|
+
return { kind: (manifest.type || 'full') === 'full' ? 'full' : 'partial', manifest };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const ID_TIME = /^.+?_(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})(?:-(\d+))?$/;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Ordering key of a backup directory: the manifest timestamp, else the time in its id (local
|
|
287
|
+
* time, as backupId writes it), else its mtime. Retention and listing share it.
|
|
288
|
+
*/
|
|
289
|
+
function sortKey(dir , { fsImpl = fs, manifest } = {}) {
|
|
290
|
+
const id = path.basename(dir);
|
|
291
|
+
const m = manifest === undefined ? classifyBackupDir(dir, { fsImpl }).manifest : manifest;
|
|
292
|
+
const match = ID_TIME.exec(id);
|
|
293
|
+
const suffix = match && match[7] ? Number(match[7]) : 1;
|
|
294
|
+
let time = m ? Date.parse(m.timestamp) : NaN;
|
|
295
|
+
if (!Number.isFinite(time) && match) {
|
|
296
|
+
const [, y, mo, d, h, mi, s] = match.map(Number);
|
|
297
|
+
time = new Date(y, mo - 1, d, h, mi, s).getTime();
|
|
298
|
+
}
|
|
299
|
+
if (!Number.isFinite(time)) {
|
|
300
|
+
try { time = fsImpl.lstatSync(dir).mtimeMs; } catch { time = 0; }
|
|
301
|
+
}
|
|
302
|
+
return { id, time, suffix };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Newest first; ties go to the larger `-n` suffix, then the id in descending order. */
|
|
306
|
+
function compareSortKeys(a , b ) {
|
|
307
|
+
if (a.time !== b.time) return b.time - a.time;
|
|
308
|
+
if (a.suffix !== b.suffix) return b.suffix - a.suffix;
|
|
309
|
+
if (a.id === b.id) return 0;
|
|
310
|
+
return a.id < b.id ? 1 : -1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Bytes of the regular files under `dir`, never following a link; null when any read fails. */
|
|
314
|
+
function backupSize(dir , { fsImpl = fs } = {}) {
|
|
315
|
+
try {
|
|
316
|
+
let total = 0;
|
|
317
|
+
const pending = [dir];
|
|
318
|
+
while (pending.length > 0) {
|
|
319
|
+
const current = pending.pop();
|
|
320
|
+
for (const name of fsImpl.readdirSync(current)) {
|
|
321
|
+
const p = path.join(current, name);
|
|
322
|
+
const st = fsImpl.lstatSync(p);
|
|
323
|
+
if (st.isDirectory()) pending.push(p);
|
|
324
|
+
else if (st.isFile()) total += st.size;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return total;
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const BYTE_UNITS = ['KiB', 'MiB', 'GiB', 'TiB'];
|
|
334
|
+
|
|
335
|
+
/** `?` for an unknown size, `<n> B` below 1 KiB, otherwise one decimal in 1024-based units. */
|
|
336
|
+
function formatBytes(n ) {
|
|
337
|
+
if (n === null || n === undefined) return '?';
|
|
338
|
+
if (n < 1024) return `${n} B`;
|
|
339
|
+
let value = n / 1024;
|
|
340
|
+
let unit = 0;
|
|
341
|
+
while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
|
|
342
|
+
value /= 1024;
|
|
343
|
+
unit += 1;
|
|
344
|
+
}
|
|
345
|
+
return `${value.toFixed(1)} ${BYTE_UNITS[unit]}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Security guard: a backup id is a directory *name*, never a path — reject traversal/absolute ids. */
|
|
349
|
+
function assertSafeBackupId(bid ) {
|
|
350
|
+
if (!bid || bid.includes('/') || bid.includes('\\') || bid === '..' || bid.includes('..')) {
|
|
351
|
+
throw new Error(`Invalid backup id: '${bid}'`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Restore a 1.19 backup id into the given tool dirs. dirs = {tool: absDstDir}. */
|
|
356
|
+
function restoreBackup({ bid, backupRoot, dirs, dryRun = false } ) {
|
|
357
|
+
assertSafeBackupId(bid);
|
|
358
|
+
// An id the user picked from `list-backups` may live in either root, so resolve it the same way
|
|
359
|
+
// the listing found it. Restoring reads the backup and writes only into the tool dirs, so a
|
|
360
|
+
// legacy source is safe; nothing here mutates the directory it was read from.
|
|
361
|
+
const found = backupReadRoots(backupRoot).find((r) => fs.existsSync(path.join(r.root, bid)));
|
|
362
|
+
if (!found) {
|
|
363
|
+
throw new Error(`Backup not found: ${bid}`);
|
|
364
|
+
}
|
|
365
|
+
const bkDir = path.join(found.root, bid);
|
|
366
|
+
|
|
367
|
+
let type = 'full';
|
|
368
|
+
let format;
|
|
369
|
+
const manifestPath = path.join(bkDir, '.manifest.json');
|
|
370
|
+
if (fs.existsSync(manifestPath)) {
|
|
371
|
+
try {
|
|
372
|
+
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
373
|
+
type = m.type || 'full';
|
|
374
|
+
format = m.format;
|
|
375
|
+
} catch { /* fall back to 'full' */ }
|
|
376
|
+
}
|
|
377
|
+
// A format-2 backup holds no tool archive or tool copy, so this path would restore nothing.
|
|
378
|
+
if (format === 2) throw new Error(`Backup ${bid} is format 2; restore it with doflow rollback`);
|
|
379
|
+
|
|
380
|
+
for (const tool of Object.keys(dirs)) {
|
|
381
|
+
const dstDir = dirs[tool];
|
|
382
|
+
if (type === 'full') {
|
|
383
|
+
const tarPath = path.join(bkDir, `${tool}.tar.gz`);
|
|
384
|
+
if (!fs.existsSync(tarPath)) continue;
|
|
385
|
+
if (dryRun) continue;
|
|
386
|
+
fs.mkdirSync(dstDir, { recursive: true });
|
|
387
|
+
execFileSync('tar', ['-xzf', tarPath, '-C', dstDir], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
388
|
+
} else {
|
|
389
|
+
const partialDir = path.join(bkDir, tool);
|
|
390
|
+
if (!fs.existsSync(partialDir)) continue;
|
|
391
|
+
if (dryRun) continue;
|
|
392
|
+
fs.mkdirSync(dstDir, { recursive: true });
|
|
393
|
+
fs.cpSync(partialDir, dstDir, { recursive: true, force: true });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Raw member lines of a tar.gz archive. A whole-home archive can list far more than the default
|
|
399
|
+
* 1 MiB of output, so the buffer is unbounded. */
|
|
400
|
+
function defaultListTar(archive ) {
|
|
401
|
+
return execFileSync('tar', ['-tzf', archive], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: Infinity })
|
|
402
|
+
.split(/\r?\n/);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** File members of an archive listing, relative to the archive root, without any `..` member. */
|
|
406
|
+
function archiveMembers(lines ) {
|
|
407
|
+
return lines
|
|
408
|
+
.map((line) => line.replace(/^\.\//, ''))
|
|
409
|
+
.filter((m) => m && m !== '.' && !m.endsWith('/') && !m.split('/').includes('..'));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Paths, relative to `root`, of the regular files under it; links are not followed. */
|
|
413
|
+
function regularFilesUnder(root , fsImpl ) {
|
|
414
|
+
const files = [];
|
|
415
|
+
const pending = [''];
|
|
416
|
+
while (pending.length > 0) {
|
|
417
|
+
const rel = pending.pop() ;
|
|
418
|
+
for (const e of fsImpl.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
419
|
+
const child = path.join(rel, e.name);
|
|
420
|
+
if (e.isDirectory()) pending.push(child);
|
|
421
|
+
else if (e.isFile()) files.push(child);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return files.sort();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Why a format-2 entry cannot be restored, or null when it can. */
|
|
428
|
+
function refusalOf(entry , target , { scope, scopeRoot, bkDir, fsImpl } ) {
|
|
429
|
+
const raw = entry.path;
|
|
430
|
+
if (entry.outside === true) {
|
|
431
|
+
if (!path.isAbsolute(raw)) return 'unsafe path';
|
|
432
|
+
if (scope !== 'global') return 'outside the project root';
|
|
433
|
+
} else if (path.isAbsolute(raw) || raw.split(/[\\/]/).includes('..') || scopeRelative(scopeRoot, target) === null) {
|
|
434
|
+
return 'unsafe path';
|
|
435
|
+
}
|
|
436
|
+
if (entry.kind === 'other' || typeof entry.stored !== 'string') return 'not a regular file when backed up';
|
|
437
|
+
const stored = path.resolve(bkDir, entry.stored);
|
|
438
|
+
if (scopeRelative(bkDir, stored) === null) return 'content missing from backup';
|
|
439
|
+
try {
|
|
440
|
+
if (!fsImpl.lstatSync(stored).isFile()) return 'content missing from backup';
|
|
441
|
+
} catch {
|
|
442
|
+
return 'content missing from backup';
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Work out what restoring backup `bid` writes, before anything is written. A format-2 backup
|
|
449
|
+
* restores per file: entries recorded absent are listed and left alone, unsafe entries are refused,
|
|
450
|
+
* and with `explicitTargets` only entries of the named harnesses count. A 1.19 backup restores per
|
|
451
|
+
* tool through restoreBackup; its snapshot lists the files that restore will overwrite.
|
|
452
|
+
* Reads only.
|
|
453
|
+
*/
|
|
454
|
+
function planRestore({ bid, backupRoot, scope, scopeRoot, targets, explicitTargets, dirs, ledgerFile = null, fsImpl = fs, listTar = defaultListTar } ) {
|
|
455
|
+
assertSafeBackupId(bid);
|
|
456
|
+
const found = backupReadRoots(backupRoot).find((r) => fsImpl.existsSync(path.join(r.root, bid)));
|
|
457
|
+
if (!found) throw new Error(`Backup not found: ${bid}`);
|
|
458
|
+
const bkDir = path.join(found.root, bid);
|
|
459
|
+
const { kind, manifest } = classifyBackupDir(bkDir, { fsImpl });
|
|
460
|
+
if (kind === 'newer') {
|
|
461
|
+
throw new Error(`Backup ${bid} was made by a newer DoFlow (format ${manifest.format}); this version cannot restore it`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const plan = {
|
|
465
|
+
bid,
|
|
466
|
+
origin: found.origin,
|
|
467
|
+
bkDir,
|
|
468
|
+
format: kind === 'format-2' ? 2 : 1,
|
|
469
|
+
type: { 'format-2': 'files', partial: 'partial' }[kind] || 'full',
|
|
470
|
+
restore: [],
|
|
471
|
+
absent: [],
|
|
472
|
+
refused: [],
|
|
473
|
+
untargeted: 0,
|
|
474
|
+
snapshot: [],
|
|
475
|
+
v1: null,
|
|
476
|
+
};
|
|
477
|
+
const wanted = new Set(targets);
|
|
478
|
+
|
|
479
|
+
if (plan.format === 2) {
|
|
480
|
+
const ledger = typeof ledgerFile === 'string' ? path.resolve(ledgerFile) : null;
|
|
481
|
+
for (const entry of Array.isArray(manifest.files) ? manifest.files : []) {
|
|
482
|
+
const harnesses = Array.isArray(entry.harnesses) ? entry.harnesses : [];
|
|
483
|
+
// The ownership ledger restores per row (executeRestore), only the rows of files this restore
|
|
484
|
+
// writes, so --target narrows it with the files and never skips it.
|
|
485
|
+
const isLedger = ledger !== null && entry.outside !== true && typeof entry.path === 'string' && entry.path !== ''
|
|
486
|
+
&& path.resolve(scopeRoot, entry.path) === ledger;
|
|
487
|
+
if (!isLedger && explicitTargets && !harnesses.some((h ) => wanted.has(h))) {
|
|
488
|
+
plan.untargeted += 1;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (typeof entry.path !== 'string' || entry.path === '') {
|
|
492
|
+
plan.refused.push({ path: String(entry.path), reason: 'unsafe path' });
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
// Relative entries resolve against today's scope root, so a moved home still restores.
|
|
496
|
+
const target = entry.outside === true ? path.resolve(entry.path) : path.resolve(scopeRoot, entry.path);
|
|
497
|
+
if (entry.existed === false) {
|
|
498
|
+
plan.absent.push(target);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const reason = refusalOf(entry, target, { scope, scopeRoot, bkDir, fsImpl });
|
|
502
|
+
if (reason) {
|
|
503
|
+
plan.refused.push({ path: reason === 'unsafe path' ? entry.path : target, reason });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
plan.restore.push({ path: target, stored: path.resolve(bkDir, entry.stored), mode: entry.mode, size: entry.size, harnesses, ...(isLedger ? { ledger: true } : {}) });
|
|
507
|
+
}
|
|
508
|
+
plan.snapshot = plan.restore.map((item ) => ({ path: item.path, harnesses: item.harnesses }));
|
|
509
|
+
return plan;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const tools = [];
|
|
513
|
+
for (const tool of wanted ) {
|
|
514
|
+
const dstDir = dirs[tool];
|
|
515
|
+
if (!dstDir) continue;
|
|
516
|
+
if (plan.type === 'full') {
|
|
517
|
+
const archive = path.join(bkDir, `${tool}.tar.gz`);
|
|
518
|
+
if (!fsImpl.existsSync(archive)) continue;
|
|
519
|
+
tools.push({ tool, dstDir, source: `${tool}.tar.gz` });
|
|
520
|
+
for (const member of archiveMembers(listTar(archive))) plan.snapshot.push({ path: path.join(dstDir, member), harnesses: [tool] });
|
|
521
|
+
} else {
|
|
522
|
+
const copyDir = path.join(bkDir, tool);
|
|
523
|
+
if (!fsImpl.existsSync(copyDir)) continue;
|
|
524
|
+
tools.push({ tool, dstDir, source: `${tool}/` });
|
|
525
|
+
for (const rel of regularFilesUnder(copyDir, fsImpl)) plan.snapshot.push({ path: path.join(dstDir, rel), harnesses: [tool] });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
plan.v1 = { tools };
|
|
529
|
+
return plan;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The ownership ledger a restore leaves: the backup's rows and tombstones for the files the restore
|
|
534
|
+
* wrote back, and the current ledger for everything else. Rows a later run wrote, rows of harnesses
|
|
535
|
+
* the backup does not hold and rows of MCP entries (a file holding only MCP entries is never backed
|
|
536
|
+
* up) stay as they are; restoring the whole old ledger would make DoFlow forget files and entries it
|
|
537
|
+
* still has on disk. With no current ledger the backup's is taken whole.
|
|
538
|
+
*/
|
|
539
|
+
function mergeRestoredLedger(backup , current , restored ) {
|
|
540
|
+
if (!current) return backup;
|
|
541
|
+
const at = (target ) => typeof target === 'string' && restored.has(path.resolve(target));
|
|
542
|
+
return {
|
|
543
|
+
...current,
|
|
544
|
+
resources: [...current.resources.filter((r ) => !at(r.target)), ...backup.resources.filter((r ) => at(r.target))],
|
|
545
|
+
tombstones: [
|
|
546
|
+
...(current.tombstones ?? []).filter((t ) => !at(t.fromTarget)),
|
|
547
|
+
...(backup.tombstones ?? []).filter((t ) => at(t.fromTarget)),
|
|
548
|
+
],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** The bytes a ledger restore item writes: the backup's ledger merged onto the current one. */
|
|
553
|
+
function restoredLedgerBytes(item , restored , fsImpl ) {
|
|
554
|
+
const backup = validateLedger(JSON.parse(fsImpl.readFileSync(item.stored, 'utf8')));
|
|
555
|
+
const current = fsImpl.existsSync(item.path) ? validateLedger(JSON.parse(fsImpl.readFileSync(item.path, 'utf8'))) : null;
|
|
556
|
+
return Buffer.from(`${JSON.stringify(validateLedger(mergeRestoredLedger(backup, current, restored)), null, 2)}\n`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Carry out a plan from planRestore. Format 2 writes each restore item through a temp file renamed
|
|
561
|
+
* over the target (or straight through a symlinked target, which stays a link); a failed item is
|
|
562
|
+
* reported and the rest continue. The ownership ledger goes last and restores only the rows of the
|
|
563
|
+
* files written back before it (mergeRestoredLedger). It never deletes a file and never writes
|
|
564
|
+
* outside `plan.restore`. A 1.19 plan goes through restoreBackup unchanged.
|
|
565
|
+
*/
|
|
566
|
+
function executeRestore(plan , { backupRoot, dryRun = false, fsImpl = fs } ) {
|
|
567
|
+
if (plan.format !== 2) {
|
|
568
|
+
const toolDirs = Object.fromEntries(plan.v1.tools.map((t ) => [t.tool, t.dstDir]));
|
|
569
|
+
restoreBackup({ bid: plan.bid, backupRoot, dirs: toolDirs, dryRun });
|
|
570
|
+
return { restored: plan.v1.tools.map((t ) => t.tool), failed: [], absent: [], refused: [], untargeted: 0, legacy: true };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const restored = [];
|
|
574
|
+
const failed = [];
|
|
575
|
+
const files = new Set ();
|
|
576
|
+
const items = [...plan.restore.filter((item ) => !item.ledger), ...plan.restore.filter((item ) => item.ledger)];
|
|
577
|
+
for (const item of items) {
|
|
578
|
+
if (dryRun) {
|
|
579
|
+
restored.push(item.path);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
let temp = null;
|
|
583
|
+
try {
|
|
584
|
+
const dir = path.dirname(item.path);
|
|
585
|
+
fsImpl.mkdirSync(dir, { recursive: true });
|
|
586
|
+
const bytes = item.ledger ? restoredLedgerBytes(item, files, fsImpl) : fsImpl.readFileSync(item.stored);
|
|
587
|
+
let isLink = false;
|
|
588
|
+
try {
|
|
589
|
+
isLink = fsImpl.lstatSync(item.path).isSymbolicLink();
|
|
590
|
+
} catch (err ) {
|
|
591
|
+
if (err.code !== 'ENOENT') throw err;
|
|
592
|
+
}
|
|
593
|
+
if (isLink) {
|
|
594
|
+
fsImpl.writeFileSync(item.path, bytes);
|
|
595
|
+
} else {
|
|
596
|
+
temp = path.join(dir, `.${path.basename(item.path)}.doflow-restore-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
597
|
+
fsImpl.writeFileSync(temp, bytes, { flag: 'wx' });
|
|
598
|
+
fsImpl.chmodSync(temp, item.mode);
|
|
599
|
+
fsImpl.renameSync(temp, item.path);
|
|
600
|
+
temp = null;
|
|
601
|
+
}
|
|
602
|
+
restored.push(item.path);
|
|
603
|
+
if (!item.ledger) files.add(path.resolve(item.path));
|
|
604
|
+
} catch (err) {
|
|
605
|
+
if (temp) {
|
|
606
|
+
try { fsImpl.rmSync(temp, { force: true }); } catch { /* best effort */ }
|
|
607
|
+
}
|
|
608
|
+
failed.push({ path: item.path, reason: errorCode(err) });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return { restored, failed, absent: plan.absent, refused: plan.refused, untargeted: plan.untargeted, legacy: false };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** One `{ row, key }` per backup dir under `root`, tagged with where it came from. Dot-named
|
|
615
|
+
* directories (temps of a run in progress) and symlinks are not backups. */
|
|
616
|
+
function readBackupRows(root , origin ) {
|
|
617
|
+
if (!fs.existsSync(root)) return [];
|
|
618
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
619
|
+
.filter((e ) => e.isDirectory() && !e.name.startsWith('.'))
|
|
620
|
+
.map((e ) => {
|
|
621
|
+
const bkDir = path.join(root, e.name);
|
|
622
|
+
const { kind, manifest: m } = classifyBackupDir(bkDir);
|
|
623
|
+
const key = sortKey(bkDir, { manifest: m });
|
|
624
|
+
const bytes = backupSize(bkDir);
|
|
625
|
+
if (kind === 'incomplete') {
|
|
626
|
+
return { key, row: { id: e.name, operation: 'unknown', type: '?', timestamp: '-', origin, backupRoot: root, format: null, complete: false, bytes } };
|
|
627
|
+
}
|
|
628
|
+
const format = kind === 'format-2' || kind === 'newer' ? m.format : 1;
|
|
629
|
+
const type = { 'format-2': 'files', full: 'full' }[kind] || m.type || '?';
|
|
630
|
+
return { key, row: { id: m.id || e.name, operation: m.operation || 'unknown', type, timestamp: m.timestamp || '-', origin, backupRoot: root, format, complete: true, bytes } };
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* List all restore points visible to this scope, newest first, reading each `.manifest.json`.
|
|
636
|
+
* Both the canonical root and the legacy pre-7de6d5f root are consulted, so restore points written
|
|
637
|
+
* before lifecycle metadata moved under `.doflow` stay visible. Every row carries `origin`
|
|
638
|
+
* ('current' | 'legacy') and the absolute `backupRoot` it was found in — the two sets are tagged,
|
|
639
|
+
* never blended into an indistinguishable list. A duplicate id resolves to the canonical copy,
|
|
640
|
+
* matching restoreBackup's precedence. Rows also carry `format` (2, 1 for 1.19, null when the
|
|
641
|
+
* manifest is missing), `complete` and `bytes` (null when unreadable), and are ordered by the
|
|
642
|
+
* same key retention uses.
|
|
643
|
+
*/
|
|
644
|
+
function listBackups(backupRoot ) {
|
|
645
|
+
const found = [];
|
|
646
|
+
const seen = new Set();
|
|
647
|
+
for (const { root, origin } of backupReadRoots(backupRoot)) {
|
|
648
|
+
for (const item of readBackupRows(root, origin)) {
|
|
649
|
+
if (seen.has(item.row.id)) continue;
|
|
650
|
+
seen.add(item.row.id);
|
|
651
|
+
found.push(item);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
found.sort((a, b) => compareSortKeys(a.key, b.key));
|
|
656
|
+
return found.map((item) => item.row);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Keep the newest `keep` backups of the canonical root and remove the rest; `keep` 0 removes
|
|
661
|
+
* nothing. Every DoFlow backup counts, 1.19 and manifest-less ones included, ordered by
|
|
662
|
+
* sortKey; other directories are left alone. Ids in `protect` (the run's own backup) are always kept. A dry run removes and sweeps
|
|
663
|
+
* nothing; `reserve` makes room for a backup the real run would add, and `kept` then counts it.
|
|
664
|
+
* Never reaches the legacy `.claude/backups` root: those may be a user's only restore points.
|
|
665
|
+
*/
|
|
666
|
+
function applyRetention({ backupRoot, keep, protect = [], reserve = 0, dryRun = false, now = Date.now(), fsImpl = fs } ) {
|
|
667
|
+
assertMutableBackupRoot(backupRoot, 'prune backups');
|
|
668
|
+
if (!Number.isInteger(keep) || keep < 0) throw new Error(`Backup retention must be a non-negative whole number, got ${keep}`);
|
|
669
|
+
const result = { keep, kept: 0, removed: [], wouldRemove: [], failed: [], sweptTemps: 0 };
|
|
670
|
+
|
|
671
|
+
// Only DoFlow backups count: a directory with a readable manifest, or one named like a backup id
|
|
672
|
+
// (a 1.19 or interrupted backup can lack a manifest). Anything else in the root is not ours to
|
|
673
|
+
// delete, and must not take a restore point's place.
|
|
674
|
+
const candidates = [];
|
|
675
|
+
const entries = fsImpl.existsSync(backupRoot) ? fsImpl.readdirSync(backupRoot, { withFileTypes: true }) : [];
|
|
676
|
+
for (const e of entries) {
|
|
677
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue;
|
|
678
|
+
const dir = path.join(backupRoot, e.name);
|
|
679
|
+
const { manifest } = classifyBackupDir(dir, { fsImpl });
|
|
680
|
+
if (!manifest && !ID_TIME.test(e.name)) continue;
|
|
681
|
+
candidates.push(sortKey(dir, { fsImpl, manifest }));
|
|
682
|
+
}
|
|
683
|
+
candidates.sort(compareSortKeys);
|
|
684
|
+
const protectedIds = new Set(protect);
|
|
685
|
+
const protectedCandidates = candidates.filter((c) => protectedIds.has(c.id));
|
|
686
|
+
const ordered = [...protectedCandidates, ...candidates.filter((c) => !protectedIds.has(c.id))];
|
|
687
|
+
const keepCount = keep === 0 ? ordered.length : Math.max(keep - reserve, protectedCandidates.length);
|
|
688
|
+
const removal = ordered.slice(keepCount).map((c) => c.id);
|
|
689
|
+
|
|
690
|
+
if (dryRun || keep === 0) {
|
|
691
|
+
if (dryRun) result.wouldRemove = removal;
|
|
692
|
+
result.kept = ordered.length - removal.length + (dryRun ? reserve : 0);
|
|
693
|
+
return result;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
result.sweptTemps = sweepStaleTemps(backupRoot, { now, fsImpl });
|
|
697
|
+
for (const id of removal) {
|
|
698
|
+
const dir = path.join(backupRoot, id);
|
|
699
|
+
// Another run's retention pass may have removed it already.
|
|
700
|
+
if (!fsImpl.existsSync(dir)) continue;
|
|
701
|
+
try {
|
|
702
|
+
fsImpl.rmSync(dir, { recursive: true, force: true });
|
|
703
|
+
result.removed.push(id);
|
|
704
|
+
} catch (err ) {
|
|
705
|
+
result.failed.push({ id, error: err.message });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
result.kept = ordered.filter((c) => fsImpl.existsSync(path.join(backupRoot, c.id))).length;
|
|
709
|
+
return result;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** The parenthesised end of a retention line: the `--prune` value given, or the default. */
|
|
713
|
+
function retentionNote(prune , keep ) {
|
|
714
|
+
if (prune === null || prune === undefined) return `keeping the newest ${keep}; --prune N changes this`;
|
|
715
|
+
if (prune === 0) return '--prune 0 keeps every backup';
|
|
716
|
+
return `--prune ${keep}`;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
module.exports = {
|
|
720
|
+
backupId, restoreBackup, listBackups, assertSafeBackupId, scopeRelative, retentionNote,
|
|
721
|
+
backupReadRoots, BACKUP_ORIGIN_CURRENT, BACKUP_ORIGIN_LEGACY,
|
|
722
|
+
BackupError, DEFAULT_BACKUP_RETENTION, createFileBackup, sweepStaleTemps, classifyBackupDir,
|
|
723
|
+
sortKey, backupSize, formatBytes, planRestore, executeRestore, mergeRestoredLedger, applyRetention,
|
|
724
|
+
};
|